Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import com.gamss.android.data.local.auth.TokenProvider
import com.gamss.android.data.local.auth.TokenProviderImpl
import com.gamss.android.data.repository.AuthRepositoryImpl
import com.gamss.android.data.repository.ConversationRepositoryImpl
import com.gamss.android.data.repository.TokenUsageRefreshNotifierImpl
import com.gamss.android.data.repository.UserRepositoryImpl
import com.gamss.android.domain.conversation.ConversationRepository
import com.gamss.android.domain.repository.AuthRepository
import com.gamss.android.domain.repository.TokenUsageRefreshNotifier
import com.gamss.android.domain.repository.UserRepository
import dagger.Binds
import dagger.Module
Expand Down Expand Up @@ -45,6 +47,11 @@ internal abstract class RepositoryModule {
conversationRepositoryImpl: ConversationRepositoryImpl
): ConversationRepository

@Binds
abstract fun bindTokenUsageRefreshNotifier(
tokenUsageRefreshNotifierImpl: TokenUsageRefreshNotifierImpl,
): TokenUsageRefreshNotifier

@Binds
abstract fun bindAuthTokenLocalDataSource(
encryptedAuthTokenLocalDataSource: EncryptedAuthTokenLocalDataSource,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package com.gamss.android.data.remote.user

import com.gamss.android.data.remote.model.response.ApiResponse
import com.gamss.android.data.remote.user.model.request.UpdateNicknameRequest
import com.gamss.android.data.remote.user.model.response.DailyTokenUsageDataResponse
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH

internal interface UserService {
Expand All @@ -13,4 +15,7 @@ internal interface UserService {

@DELETE("/api/members/me")
suspend fun secessionUser(): ApiResponse<String>

@GET("/api/members/me/token-usage")
suspend fun getDailyTokenUsage(): ApiResponse<DailyTokenUsageDataResponse>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.gamss.android.data.remote.user.model.response

import com.gamss.android.domain.model.DailyTokenUsage
import kotlinx.serialization.Serializable

@Serializable
internal data class DailyTokenUsageDataResponse(
val usedTokens: Long,
val dailyLimit: Long? = null,
val exceeded: Boolean,
) {
fun toDomain() = DailyTokenUsage(
usedTokens = usedTokens,
dailyLimit = dailyLimit,
exceeded = exceeded,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.gamss.android.data.repository

import com.gamss.android.domain.repository.TokenUsageRefreshNotifier
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
internal class TokenUsageRefreshNotifierImpl @Inject constructor() : TokenUsageRefreshNotifier {

/** 신호는 마지막 한 번만 의미가 있다. 버퍼가 차면 오래된 쪽을 버려 tryEmit 이 실패하지 않게 한다. */
private val events = MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)

override val refreshEvents: Flow<Unit> = events.asSharedFlow()

override fun requestRefresh() {
events.tryEmit(Unit)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.gamss.android.data.repository
import com.gamss.android.core.common.AppResult
import com.gamss.android.data.remote.user.UserService
import com.gamss.android.data.remote.user.model.request.UpdateNicknameRequest
import com.gamss.android.domain.model.DailyTokenUsage
import com.gamss.android.domain.repository.UserRepository
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -24,4 +25,11 @@ internal class UserRepositoryImpl @Inject constructor(
userService.secessionUser()
}
}

override suspend fun getDailyTokenUsage(): AppResult<DailyTokenUsage> {
return runCatchingApiCall {
val response = userService.getDailyTokenUsage()
checkNotNull(response.data) { "No available daily token usage data" }.toDomain()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,10 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException

@OptIn(ExperimentalCoroutinesApi::class)
Expand Down Expand Up @@ -193,9 +189,4 @@ class AuthRepositoryImplTest {
refreshToken = "new-refresh-token",
),
)

private fun httpException(statusCode: Int): HttpException {
val errorBody = "{}".toResponseBody("application/json".toMediaType())
return HttpException(Response.error<ApiResponse<LoginResponse>>(statusCode, errorBody))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.gamss.android.data.repository

import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.HttpException
import retrofit2.Response

internal fun httpException(statusCode: Int): HttpException {
val errorBody = "{}".toResponseBody("application/json".toMediaType())
return HttpException(Response.error<Unit>(statusCode, errorBody))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.gamss.android.data.repository

import com.gamss.android.core.common.AppResult
import com.gamss.android.data.remote.model.response.ApiResponse
import com.gamss.android.data.remote.user.UserService
import com.gamss.android.data.remote.user.model.response.DailyTokenUsageDataResponse
import com.gamss.android.domain.model.SessionExpiredException
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class UserRepositoryImplTest {

private val userService: UserService = mockk()

@Test
fun `token usage data is mapped to the domain model`() = runTest {
coEvery { userService.getDailyTokenUsage() } returns ApiResponse(
success = true,
data = DailyTokenUsageDataResponse(
usedTokens = 12_000,
dailyLimit = 100_000,
exceeded = false,
),
)

val result = repository().getDailyTokenUsage()

assertTrue(result is AppResult.Success)
assertEquals(12_000, (result as AppResult.Success).data.usedTokens)
assertEquals(100_000L, result.data.dailyLimit)
}

@Test
fun `missing data is returned as failure`() = runTest {
coEvery { userService.getDailyTokenUsage() } returns ApiResponse(success = true)

val result = repository().getDailyTokenUsage()

assertTrue(result is AppResult.Failure)
}

@Test
fun `unauthorized response is mapped to session expiration`() = runTest {
coEvery { userService.getDailyTokenUsage() } throws httpException(401)

val result = repository().getDailyTokenUsage()

assertTrue(result is AppResult.Failure)
assertTrue((result as AppResult.Failure).throwable is SessionExpiredException)
}

private fun repository() = UserRepositoryImpl(userService)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.gamss.android.domain.model

data class DailyTokenUsage(
val usedTokens: Long,
val dailyLimit: Long?,
val exceeded: Boolean,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.gamss.android.domain.repository

import kotlinx.coroutines.flow.Flow

interface TokenUsageRefreshNotifier {
val refreshEvents: Flow<Unit>

fun requestRefresh()
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.gamss.android.domain.repository

import com.gamss.android.core.common.AppResult
import com.gamss.android.domain.model.DailyTokenUsage

interface UserRepository {
suspend fun updateNickname(nickname: String): AppResult<String>

suspend fun secession(): AppResult<Unit>

suspend fun getDailyTokenUsage(): AppResult<DailyTokenUsage>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.gamss.android.domain.usecase

import com.gamss.android.core.common.AppResult
import com.gamss.android.domain.model.DailyTokenUsage
import com.gamss.android.domain.repository.UserRepository
import javax.inject.Inject

class GetDailyTokenUsageUseCase @Inject constructor(
private val userRepository: UserRepository,
) : NoParamUseCase<AppResult<DailyTokenUsage>> {

override suspend fun invoke(): AppResult<DailyTokenUsage> =
userRepository.getDailyTokenUsage()
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.gamss.android.domain.conversation.Message
import com.gamss.android.domain.conversation.MessageSender
import com.gamss.android.domain.conversation.SendMessageUseCase
import com.gamss.android.domain.conversation.nextCommentRevealGapMillis
import com.gamss.android.domain.repository.TokenUsageRefreshNotifier
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
Expand All @@ -27,6 +28,7 @@ class ChatRoomViewModel @Inject constructor(
private val sendMessage: SendMessageUseCase,
private val getMessages: GetMessagesUseCase,
private val summaryStore: ConversationSummaryStore,
private val tokenUsageRefreshNotifier: TokenUsageRefreshNotifier,
) : ViewModel(),
ContainerHost<ChatRoomState, ChatRoomSideEffect> {

Expand Down Expand Up @@ -130,6 +132,7 @@ class ChatRoomViewModel @Inject constructor(
}
launchCommentReveal()
sent.commentStatus.toUserMessage()?.let { postSideEffect(ChatRoomSideEffect.ShowToast(it)) }
tokenUsageRefreshNotifier.requestRefresh()
// 요약기가 돌 수 있어 화면 갱신 뒤에 둔다.
summaryStore.add(sent.message.content)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ import com.gamss.android.domain.conversation.MessageSender
import com.gamss.android.domain.conversation.SendMessageUseCase
import com.gamss.android.domain.conversation.SentMessage
import com.gamss.android.domain.emotion.EmotionCharacter
import com.gamss.android.domain.repository.TokenUsageRefreshNotifier
import com.gamss.android.domain.summary.DiarySummarizer
import com.gamss.android.domain.summary.UtteranceTokenCounter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
Expand Down Expand Up @@ -163,18 +166,70 @@ class ChatRoomRevealTest {
}
}

@Test
fun 전송이_성공하면_토큰_사용량_갱신을_요청한다() = runTest {
val notifier = RecordingTokenUsageRefreshNotifier()
val viewModel = viewModel(FakeConversationRepository(commentCount = 1), notifier)

viewModel.test(this) {
expectInitialState()
containerHost.onInputChange(INPUT)
skipItems(1) // input 반영
containerHost.onSend()
skipItems(1) // isSending = true
awaitState() // 전송 성공 반영

assertEquals(1, notifier.refreshCount)

cancelAndIgnoreRemainingItems()
}
}

@Test
fun 전송이_실패하면_토큰_사용량_갱신을_요청하지_않는다() = runTest {
val notifier = RecordingTokenUsageRefreshNotifier()
val repository = FakeConversationRepository(commentCount = 1, failing = true)
val viewModel = viewModel(repository, notifier)

viewModel.test(this) {
expectInitialState()
containerHost.onInputChange(INPUT)
skipItems(1) // input 반영
containerHost.onSend()
skipItems(1) // isSending = true
awaitState() // 실패 반영

assertEquals(0, notifier.refreshCount)

cancelAndIgnoreRemainingItems()
}
}

private fun viewModel(commentCount: Int): ChatRoomViewModel =
viewModel(FakeConversationRepository(commentCount))

private fun viewModel(conversationRepository: FakeConversationRepository): ChatRoomViewModel {
return ChatRoomViewModel(
sendMessage = SendMessageUseCase(conversationRepository),
getMessages = GetMessagesUseCase(conversationRepository),
summaryStore = ConversationSummaryStore(
summarizer = PassThroughSummarizer,
tokenCounter = CharLengthTokenCounter,
),
)
private fun viewModel(
conversationRepository: FakeConversationRepository,
tokenUsageRefreshNotifier: TokenUsageRefreshNotifier = RecordingTokenUsageRefreshNotifier(),
): ChatRoomViewModel = ChatRoomViewModel(
sendMessage = SendMessageUseCase(conversationRepository),
getMessages = GetMessagesUseCase(conversationRepository),
summaryStore = ConversationSummaryStore(
summarizer = PassThroughSummarizer,
tokenCounter = CharLengthTokenCounter,
),
tokenUsageRefreshNotifier = tokenUsageRefreshNotifier,
)

private class RecordingTokenUsageRefreshNotifier : TokenUsageRefreshNotifier {
var refreshCount = 0
private set

override val refreshEvents: Flow<Unit> = emptyFlow()

override fun requestRefresh() {
refreshCount++
}
}

private object PassThroughSummarizer : DiarySummarizer {
Expand Down
3 changes: 3 additions & 0 deletions feature/home/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ dependencies {
implementation(projects.core.common)
implementation(projects.core.ui)
implementation(projects.domain)

// orbit-test 가 전이로 가져오지만, 직접 쓰는 API 라 명시한다.
testImplementation(libs.kotlinx.coroutines.test)
}
Loading