重构: 完成网络端口迁移
This commit is contained in:
@@ -9,6 +9,8 @@ import com.huaga.life_echo.data.repository.ConversationRepository
|
||||
import com.huaga.life_echo.data.repository.MessageRepository
|
||||
import com.huaga.life_echo.data.repository.PaymentRepository
|
||||
import com.huaga.life_echo.data.repository.ProfileRepository
|
||||
import com.huaga.life_echo.feature.conversation.adapters.ConversationApiAdapter
|
||||
import com.huaga.life_echo.feature.conversation.ports.ConversationApiPort
|
||||
import com.huaga.life_echo.feature.memoir.adapters.MemoirApiAdapter
|
||||
import com.huaga.life_echo.feature.memoir.ports.MemoirApiPort
|
||||
import com.huaga.life_echo.feature.payment.adapters.PaymentApiAdapter
|
||||
@@ -61,6 +63,7 @@ class AppContainer(private val context: Context) {
|
||||
val webSocketClient: WebSocketClient by lazy { WebSocketClient(realtimeTransportProvider) }
|
||||
|
||||
// Feature ports
|
||||
val conversationApi: ConversationApiPort by lazy { ConversationApiAdapter(apiService) }
|
||||
val memoirApi: MemoirApiPort by lazy { MemoirApiAdapter(apiService) }
|
||||
val profileApi: ProfileApiPort by lazy { ProfileApiAdapter(apiService) }
|
||||
val paymentApi: PaymentApiPort by lazy { PaymentApiAdapter(apiService) }
|
||||
@@ -70,7 +73,7 @@ class AppContainer(private val context: Context) {
|
||||
ConversationRepository(
|
||||
conversationDao = database.conversationDao(),
|
||||
segmentDao = database.conversationSegmentDao(),
|
||||
apiService = apiService
|
||||
conversationApi = conversationApi,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,7 +84,7 @@ class AppContainer(private val context: Context) {
|
||||
val messageRepository by lazy {
|
||||
MessageRepository(
|
||||
messageDao = database.messageDao(),
|
||||
apiService = apiService
|
||||
conversationApi = conversationApi,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package com.huaga.life_echo.data.repository
|
||||
|
||||
import com.huaga.life_echo.data.database.*
|
||||
import com.huaga.life_echo.network.ApiService
|
||||
import com.huaga.life_echo.network.models.ConversationListItemDto
|
||||
import com.huaga.life_echo.feature.conversation.ports.ConversationApiPort
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class ConversationRepository(
|
||||
private val conversationDao: ConversationDao,
|
||||
private val segmentDao: ConversationSegmentDao,
|
||||
private val apiService: ApiService
|
||||
private val conversationApi: ConversationApiPort,
|
||||
) {
|
||||
fun getAllConversations(): Flow<List<Conversation>> {
|
||||
return conversationDao.getAllConversations()
|
||||
@@ -27,7 +26,7 @@ class ConversationRepository(
|
||||
}
|
||||
|
||||
suspend fun deleteConversation(id: String): Result<Unit> {
|
||||
val result = apiService.deleteConversation(id)
|
||||
val result = conversationApi.deleteConversation(id)
|
||||
return result.fold(
|
||||
onSuccess = {
|
||||
// 从本地数据库删除
|
||||
@@ -64,7 +63,7 @@ class ConversationRepository(
|
||||
return Result.success(existingConversation.id)
|
||||
}
|
||||
|
||||
val result = apiService.createConversation()
|
||||
val result = conversationApi.createConversation()
|
||||
return result.fold(
|
||||
onSuccess = { response ->
|
||||
// 将新对话保存到本地数据库
|
||||
@@ -101,7 +100,7 @@ class ConversationRepository(
|
||||
* 从API同步对话列表
|
||||
*/
|
||||
suspend fun syncConversations() {
|
||||
val result = apiService.getConversationList()
|
||||
val result = conversationApi.getConversationList()
|
||||
result.fold(
|
||||
onSuccess = { conversations ->
|
||||
// 将DTO转换为Entity并保存到数据库
|
||||
@@ -133,4 +132,3 @@ class ConversationRepository(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,19 @@ package com.huaga.life_echo.data.repository
|
||||
|
||||
import com.huaga.life_echo.data.database.Message
|
||||
import com.huaga.life_echo.data.database.MessageDao
|
||||
import com.huaga.life_echo.network.ApiService
|
||||
import com.huaga.life_echo.network.models.MessageDto
|
||||
import com.huaga.life_echo.feature.conversation.ports.ConversationApiPort
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class MessageRepository(
|
||||
private val messageDao: MessageDao,
|
||||
private val apiService: ApiService
|
||||
private val conversationApi: ConversationApiPort,
|
||||
) {
|
||||
fun getMessagesByConversationId(conversationId: String): Flow<List<Message>> {
|
||||
return messageDao.getMessagesByConversationId(conversationId)
|
||||
}
|
||||
|
||||
suspend fun syncMessages(conversationId: String) {
|
||||
val result = apiService.getMessages(conversationId)
|
||||
val result = conversationApi.getMessages(conversationId)
|
||||
result.getOrNull()?.let { messages ->
|
||||
val dbMessages = messages.map { dto ->
|
||||
Message(
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.huaga.life_echo.feature.conversation.adapters
|
||||
import com.huaga.life_echo.feature.conversation.ports.ConversationApiPort
|
||||
import com.huaga.life_echo.network.ApiService
|
||||
import com.huaga.life_echo.network.models.ChapterDto
|
||||
import com.huaga.life_echo.network.models.ConversationListItemDto
|
||||
import com.huaga.life_echo.network.models.CreateConversationResponse
|
||||
import com.huaga.life_echo.network.models.MessageDto
|
||||
import com.huaga.life_echo.network.models.TasksStatusDto
|
||||
@@ -15,6 +16,14 @@ class ConversationApiAdapter(
|
||||
return apiService.createConversation()
|
||||
}
|
||||
|
||||
override suspend fun getConversationList(): Result<List<ConversationListItemDto>> {
|
||||
return apiService.getConversationList()
|
||||
}
|
||||
|
||||
override suspend fun deleteConversation(conversationId: String): Result<Unit> {
|
||||
return apiService.deleteConversation(conversationId)
|
||||
}
|
||||
|
||||
override suspend fun getMessages(conversationId: String): Result<List<MessageDto>> {
|
||||
return apiService.getMessages(conversationId)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class ConversationRealtimeAdapter(
|
||||
override val state: StateFlow<ConversationRealtimePort.State> = _state.asStateFlow()
|
||||
|
||||
override suspend fun prepare() {
|
||||
webSocketClient.prepare()
|
||||
_state.value = ConversationRealtimePort.State.Preparing
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.huaga.life_echo.feature.conversation.ports
|
||||
|
||||
import com.huaga.life_echo.network.models.ChapterDto
|
||||
import com.huaga.life_echo.network.models.ConversationListItemDto
|
||||
import com.huaga.life_echo.network.models.CreateConversationResponse
|
||||
import com.huaga.life_echo.network.models.MessageDto
|
||||
import com.huaga.life_echo.network.models.TasksStatusDto
|
||||
|
||||
interface ConversationApiPort {
|
||||
suspend fun createConversation(): Result<CreateConversationResponse>
|
||||
suspend fun getConversationList(): Result<List<ConversationListItemDto>>
|
||||
suspend fun deleteConversation(conversationId: String): Result<Unit>
|
||||
suspend fun getMessages(conversationId: String): Result<List<MessageDto>>
|
||||
suspend fun getTasksStatus(): Result<TasksStatusDto>
|
||||
suspend fun getChapters(): Result<List<ChapterDto>>
|
||||
|
||||
@@ -47,6 +47,10 @@ open class WebSocketClient(
|
||||
private const val MAX_RECONNECT_ATTEMPTS = 5
|
||||
}
|
||||
|
||||
open fun prepare() {
|
||||
transportProvider.warmUp()
|
||||
}
|
||||
|
||||
open suspend fun connect(
|
||||
conversationId: String,
|
||||
token: String? = null,
|
||||
|
||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
class RealtimeTransportProvider(
|
||||
private val factory: () -> HttpClient,
|
||||
) {
|
||||
private val lock = Any()
|
||||
private var client: HttpClient? = null
|
||||
private val _readiness = MutableStateFlow(NetworkReadiness.NotInitialized)
|
||||
val readiness: StateFlow<NetworkReadiness> = _readiness.asStateFlow()
|
||||
@@ -17,15 +18,21 @@ class RealtimeTransportProvider(
|
||||
}
|
||||
|
||||
fun getClient(): HttpClient {
|
||||
return client ?: factory().also {
|
||||
return synchronized(lock) {
|
||||
client ?: factory().also {
|
||||
client = it
|
||||
_readiness.value = NetworkReadiness.ClientReady
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
client?.close()
|
||||
val clientToClose = synchronized(lock) {
|
||||
client.also {
|
||||
client = null
|
||||
_readiness.value = NetworkReadiness.NotInitialized
|
||||
}
|
||||
}
|
||||
clientToClose?.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,25 +10,32 @@ enum class RestClientProfile { AUTH, API }
|
||||
class RestClientProvider(
|
||||
private val factory: (RestClientProfile) -> HttpClient,
|
||||
) {
|
||||
private val lock = Any()
|
||||
private val clients = mutableMapOf<RestClientProfile, HttpClient>()
|
||||
private val _readiness = MutableStateFlow(NetworkReadiness.NotInitialized)
|
||||
val readiness: StateFlow<NetworkReadiness> = _readiness.asStateFlow()
|
||||
|
||||
fun getClient(profile: RestClientProfile): HttpClient {
|
||||
return clients.getOrPut(profile) {
|
||||
return synchronized(lock) {
|
||||
clients.getOrPut(profile) {
|
||||
factory(profile).also {
|
||||
_readiness.value = NetworkReadiness.ClientReady
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun warmUp() {
|
||||
RestClientProfile.entries.forEach { getClient(it) }
|
||||
}
|
||||
|
||||
fun close() {
|
||||
clients.values.forEach { it.close() }
|
||||
val clientsToClose = synchronized(lock) {
|
||||
clients.values.toList().also {
|
||||
clients.clear()
|
||||
_readiness.value = NetworkReadiness.NotInitialized
|
||||
}
|
||||
}
|
||||
clientsToClose.forEach { it.close() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,11 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AuthViewModel(private val context: Context) : ViewModel() {
|
||||
private val authService = AuthService()
|
||||
class AuthViewModel(
|
||||
context: Context,
|
||||
private val authService: AuthService,
|
||||
private val tokenInitializer: (Context) -> Unit = TokenManager::initialize,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
@@ -48,7 +51,7 @@ class AuthViewModel(private val context: Context) : ViewModel() {
|
||||
val smsCountdown: StateFlow<Int> = _smsCountdown.asStateFlow()
|
||||
|
||||
init {
|
||||
TokenManager.initialize(context)
|
||||
tokenInitializer(context)
|
||||
checkAuthStatus()
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.huaga.life_echo.app.AppContainer
|
||||
import com.huaga.life_echo.app.LifeEchoApp
|
||||
import com.huaga.life_echo.feature.conversation.adapters.ConversationApiAdapter
|
||||
import com.huaga.life_echo.feature.conversation.adapters.ConversationRealtimeAdapter
|
||||
import com.huaga.life_echo.feature.memoir.adapters.MemoirApiAdapter
|
||||
|
||||
class ViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
|
||||
|
||||
@@ -23,7 +21,7 @@ class ViewModelFactory(private val context: Context) : ViewModelProvider.Factory
|
||||
chapterRepository = container.chapterRepository,
|
||||
messageRepository = container.messageRepository,
|
||||
context = context,
|
||||
conversationApi = ConversationApiAdapter(container.apiService),
|
||||
conversationApi = container.conversationApi,
|
||||
conversationRealtime = ConversationRealtimeAdapter(container.webSocketClient),
|
||||
) as T
|
||||
}
|
||||
@@ -39,7 +37,10 @@ class ViewModelFactory(private val context: Context) : ViewModelProvider.Factory
|
||||
) as T
|
||||
}
|
||||
modelClass.isAssignableFrom(AuthViewModel::class.java) -> {
|
||||
AuthViewModel(context = context) as T
|
||||
AuthViewModel(
|
||||
context = context,
|
||||
authService = container.authService,
|
||||
) as T
|
||||
}
|
||||
modelClass.isAssignableFrom(PaymentViewModel::class.java) -> {
|
||||
PaymentViewModel(
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.huaga.life_echo.data.repository
|
||||
|
||||
import com.huaga.life_echo.data.database.Conversation
|
||||
import com.huaga.life_echo.data.database.ConversationDao
|
||||
import com.huaga.life_echo.data.database.ConversationSegment
|
||||
import com.huaga.life_echo.data.database.ConversationSegmentDao
|
||||
import com.huaga.life_echo.feature.conversation.ports.ConversationApiPort
|
||||
import com.huaga.life_echo.network.models.ChapterDto
|
||||
import com.huaga.life_echo.network.models.ConversationListItemDto
|
||||
import com.huaga.life_echo.network.models.CreateConversationResponse
|
||||
import com.huaga.life_echo.network.models.MessageDto
|
||||
import com.huaga.life_echo.network.models.TasksStatusDto
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ConversationRepositoryTest {
|
||||
|
||||
@Test
|
||||
fun sync_conversations_uses_conversation_port_and_persists_results() = runTest {
|
||||
val dao = FakeConversationDao()
|
||||
val api = FakeConversationApiPort(
|
||||
conversationListResult = Result.success(
|
||||
listOf(
|
||||
ConversationListItemDto(
|
||||
id = "conversation-1",
|
||||
title = "Title",
|
||||
avatarUrl = null,
|
||||
latestMessagePreview = "Hello",
|
||||
latestMessageTime = 123L,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val repository = ConversationRepository(
|
||||
conversationDao = dao,
|
||||
segmentDao = FakeConversationSegmentDao(),
|
||||
conversationApi = api,
|
||||
)
|
||||
|
||||
repository.syncConversations()
|
||||
|
||||
assertEquals(1, api.getConversationListCalls)
|
||||
assertEquals(listOf("conversation-1"), dao.insertedConversations.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delete_conversation_uses_conversation_port_and_removes_local_row() = runTest {
|
||||
val existing = Conversation(
|
||||
id = "conversation-1",
|
||||
userId = "user-1",
|
||||
startedAt = 1L,
|
||||
endedAt = null,
|
||||
durationSeconds = 0,
|
||||
summary = null,
|
||||
currentTopic = null,
|
||||
conversationStage = null,
|
||||
)
|
||||
val dao = FakeConversationDao(existingConversation = existing)
|
||||
val api = FakeConversationApiPort(deleteConversationResult = Result.success(Unit))
|
||||
val repository = ConversationRepository(
|
||||
conversationDao = dao,
|
||||
segmentDao = FakeConversationSegmentDao(),
|
||||
conversationApi = api,
|
||||
)
|
||||
|
||||
repository.deleteConversation("conversation-1")
|
||||
|
||||
assertEquals(listOf("conversation-1"), api.deletedConversationIds)
|
||||
assertEquals(listOf("conversation-1"), dao.deletedConversations.map { it.id })
|
||||
}
|
||||
|
||||
private class FakeConversationDao(
|
||||
private var existingConversation: Conversation? = null,
|
||||
) : ConversationDao {
|
||||
val insertedConversations = mutableListOf<Conversation>()
|
||||
val deletedConversations = mutableListOf<Conversation>()
|
||||
|
||||
override fun getAllConversations() = flowOf(emptyList<Conversation>())
|
||||
override suspend fun getConversationById(id: String): Conversation? =
|
||||
existingConversation?.takeIf { it.id == id }
|
||||
override suspend fun getLatestEmptyConversation(): Conversation? = null
|
||||
override suspend fun insertConversation(conversation: Conversation) {
|
||||
insertedConversations += conversation
|
||||
existingConversation = conversation
|
||||
}
|
||||
override suspend fun updateConversation(conversation: Conversation) = Unit
|
||||
override suspend fun deleteConversation(conversation: Conversation) {
|
||||
deletedConversations += conversation
|
||||
if (existingConversation?.id == conversation.id) {
|
||||
existingConversation = null
|
||||
}
|
||||
}
|
||||
override suspend fun deleteOtherEmptyConversations(keepId: String) = Unit
|
||||
}
|
||||
|
||||
private class FakeConversationSegmentDao : ConversationSegmentDao {
|
||||
override fun getSegmentsByConversationId(conversationId: String) =
|
||||
flowOf(emptyList<ConversationSegment>())
|
||||
override suspend fun insertSegment(segment: ConversationSegment) = Unit
|
||||
override suspend fun insertSegments(segments: List<ConversationSegment>) = Unit
|
||||
override suspend fun updateSegment(segment: ConversationSegment) = Unit
|
||||
override suspend fun deleteSegment(segment: ConversationSegment) = Unit
|
||||
}
|
||||
|
||||
private class FakeConversationApiPort(
|
||||
private val conversationListResult: Result<List<ConversationListItemDto>> =
|
||||
Result.success(emptyList()),
|
||||
private val deleteConversationResult: Result<Unit> =
|
||||
Result.failure(Exception("not configured")),
|
||||
) : ConversationApiPort {
|
||||
var getConversationListCalls: Int = 0
|
||||
private set
|
||||
val deletedConversationIds = mutableListOf<String>()
|
||||
|
||||
override suspend fun createConversation(): Result<CreateConversationResponse> =
|
||||
Result.failure(Exception("not configured"))
|
||||
|
||||
override suspend fun getMessages(conversationId: String): Result<List<MessageDto>> =
|
||||
Result.failure(Exception("not configured"))
|
||||
|
||||
override suspend fun getTasksStatus(): Result<TasksStatusDto> =
|
||||
Result.failure(Exception("not configured"))
|
||||
|
||||
override suspend fun getChapters(): Result<List<ChapterDto>> =
|
||||
Result.failure(Exception("not configured"))
|
||||
|
||||
override suspend fun clearTasks(): Result<Unit> =
|
||||
Result.failure(Exception("not configured"))
|
||||
|
||||
override suspend fun getConversationList(): Result<List<ConversationListItemDto>> {
|
||||
getConversationListCalls += 1
|
||||
return conversationListResult
|
||||
}
|
||||
|
||||
override suspend fun deleteConversation(conversationId: String): Result<Unit> {
|
||||
deletedConversationIds += conversationId
|
||||
return deleteConversationResult
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.huaga.life_echo.data.repository
|
||||
|
||||
import com.huaga.life_echo.data.database.Message
|
||||
import com.huaga.life_echo.data.database.MessageDao
|
||||
import com.huaga.life_echo.feature.conversation.ports.ConversationApiPort
|
||||
import com.huaga.life_echo.network.models.ChapterDto
|
||||
import com.huaga.life_echo.network.models.ConversationListItemDto
|
||||
import com.huaga.life_echo.network.models.CreateConversationResponse
|
||||
import com.huaga.life_echo.network.models.MessageDto
|
||||
import com.huaga.life_echo.network.models.TasksStatusDto
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class MessageRepositoryTest {
|
||||
|
||||
@Test
|
||||
fun sync_messages_uses_conversation_port_and_persists_messages() = runTest {
|
||||
val dao = FakeMessageDao()
|
||||
val api = FakeConversationApiPort(
|
||||
messagesResult = Result.success(
|
||||
listOf(
|
||||
MessageDto(
|
||||
id = "message-1",
|
||||
conversationId = "conversation-1",
|
||||
content = "Hello",
|
||||
senderType = "user",
|
||||
timestamp = 123L,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val repository = MessageRepository(
|
||||
messageDao = dao,
|
||||
conversationApi = api,
|
||||
)
|
||||
|
||||
repository.syncMessages("conversation-1")
|
||||
|
||||
assertEquals(listOf("conversation-1"), api.requestedMessageConversationIds)
|
||||
assertEquals(listOf("message-1"), dao.insertedMessages.flatten().map { it.id })
|
||||
}
|
||||
|
||||
private class FakeMessageDao : MessageDao {
|
||||
val insertedMessages = mutableListOf<List<Message>>()
|
||||
|
||||
override fun getMessagesByConversationId(conversationId: String) = flowOf(emptyList<Message>())
|
||||
override suspend fun getMessageById(id: String): Message? = null
|
||||
override suspend fun insertMessage(message: Message) = Unit
|
||||
override suspend fun insertMessages(messages: List<Message>) {
|
||||
insertedMessages += messages
|
||||
}
|
||||
override suspend fun updateMessage(message: Message) = Unit
|
||||
override suspend fun deleteMessage(message: Message) = Unit
|
||||
override suspend fun deleteMessagesByConversationId(conversationId: String) = Unit
|
||||
}
|
||||
|
||||
private class FakeConversationApiPort(
|
||||
private val messagesResult: Result<List<MessageDto>>,
|
||||
) : ConversationApiPort {
|
||||
val requestedMessageConversationIds = mutableListOf<String>()
|
||||
|
||||
override suspend fun createConversation(): Result<CreateConversationResponse> =
|
||||
Result.failure(Exception("not configured"))
|
||||
|
||||
override suspend fun getConversationList(): Result<List<ConversationListItemDto>> =
|
||||
Result.failure(Exception("not configured"))
|
||||
|
||||
override suspend fun deleteConversation(conversationId: String): Result<Unit> =
|
||||
Result.failure(Exception("not configured"))
|
||||
|
||||
override suspend fun getMessages(conversationId: String): Result<List<MessageDto>> {
|
||||
requestedMessageConversationIds += conversationId
|
||||
return messagesResult
|
||||
}
|
||||
|
||||
override suspend fun getTasksStatus(): Result<TasksStatusDto> =
|
||||
Result.failure(Exception("not configured"))
|
||||
|
||||
override suspend fun getChapters(): Result<List<ChapterDto>> =
|
||||
Result.failure(Exception("not configured"))
|
||||
|
||||
override suspend fun clearTasks(): Result<Unit> =
|
||||
Result.failure(Exception("not configured"))
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ class ConversationRealtimeAdapterTest {
|
||||
|
||||
adapter.prepare()
|
||||
|
||||
assertEquals(1, fakeSocketClient.prepareCalls)
|
||||
assertEquals(ConversationRealtimePort.State.Preparing, adapter.state.value)
|
||||
}
|
||||
|
||||
@@ -62,6 +63,12 @@ class ConversationRealtimeAdapterTest {
|
||||
private var capturedOnMessage: ((WebSocketMessage) -> Unit)? = null
|
||||
private var capturedOnError: ((String) -> Unit)? = null
|
||||
private var connected = false
|
||||
var prepareCalls: Int = 0
|
||||
private set
|
||||
|
||||
override fun prepare() {
|
||||
prepareCalls += 1
|
||||
}
|
||||
|
||||
override suspend fun connect(
|
||||
conversationId: String,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.huaga.life_echo.network.runtime
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import org.mockito.Mockito
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal fun requestClientsConcurrently(
|
||||
parallelism: Int,
|
||||
request: () -> HttpClient,
|
||||
): List<HttpClient> {
|
||||
val executor = Executors.newFixedThreadPool(parallelism)
|
||||
val startGate = CountDownLatch(1)
|
||||
return try {
|
||||
val futures: List<Future<HttpClient>> = List(parallelism) {
|
||||
executor.submit(
|
||||
Callable {
|
||||
startGate.await(2, TimeUnit.SECONDS)
|
||||
request()
|
||||
}
|
||||
)
|
||||
}
|
||||
startGate.countDown()
|
||||
futures.map { it.get(2, TimeUnit.SECONDS) }
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun slowMockClientFactory(
|
||||
onCreate: () -> Unit,
|
||||
): () -> HttpClient = {
|
||||
onCreate()
|
||||
Thread.sleep(75)
|
||||
Mockito.mock(HttpClient::class.java)
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package com.huaga.life_echo.network.runtime
|
||||
import io.ktor.client.HttpClient
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class RealtimeTransportProviderTest {
|
||||
|
||||
@@ -40,4 +42,21 @@ class RealtimeTransportProviderTest {
|
||||
|
||||
assertEquals(NetworkReadiness.NotInitialized, provider.readiness.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrent_first_access_only_creates_one_client() {
|
||||
val createCount = AtomicInteger(0)
|
||||
val provider = RealtimeTransportProvider(
|
||||
factory = slowMockClientFactory {
|
||||
createCount.incrementAndGet()
|
||||
}
|
||||
)
|
||||
|
||||
val clients = requestClientsConcurrently(parallelism = 8) {
|
||||
provider.getClient()
|
||||
}
|
||||
|
||||
assertEquals(1, createCount.get())
|
||||
assertTrue(clients.all { it === clients.first() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import io.ktor.client.HttpClient
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class RestClientProviderTest {
|
||||
|
||||
@@ -63,4 +65,21 @@ class RestClientProviderTest {
|
||||
|
||||
assertEquals(NetworkReadiness.NotInitialized, provider.readiness.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrent_first_access_only_creates_one_client_per_profile() {
|
||||
val createCount = AtomicInteger(0)
|
||||
val provider = RestClientProvider { _ ->
|
||||
slowMockClientFactory {
|
||||
createCount.incrementAndGet()
|
||||
}.invoke()
|
||||
}
|
||||
|
||||
val clients = requestClientsConcurrently(parallelism = 8) {
|
||||
provider.getClient(RestClientProfile.AUTH)
|
||||
}
|
||||
|
||||
assertEquals(1, createCount.get())
|
||||
assertTrue(clients.all { it === clients.first() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.huaga.life_echo.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import com.huaga.life_echo.network.AuthService
|
||||
import com.huaga.life_echo.network.runtime.RestClientProvider
|
||||
import com.huaga.life_echo.testutil.MainDispatcherRule
|
||||
import io.ktor.client.HttpClient
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AuthViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
@Test
|
||||
fun uses_injected_auth_service_instance() {
|
||||
val context = Mockito.mock(Context::class.java)
|
||||
val authService = AuthService(
|
||||
provider = RestClientProvider { Mockito.mock(HttpClient::class.java) }
|
||||
)
|
||||
|
||||
val viewModel = AuthViewModel(
|
||||
context = context,
|
||||
authService = authService,
|
||||
tokenInitializer = {},
|
||||
)
|
||||
|
||||
val authServiceField = AuthViewModel::class.java.getDeclaredField("authService").apply {
|
||||
isAccessible = true
|
||||
}
|
||||
|
||||
assertSame(authService, authServiceField.get(viewModel))
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,9 @@ import com.huaga.life_echo.feature.voice.RecorderEngine
|
||||
import com.huaga.life_echo.feature.voice.RecorderStartResult
|
||||
import com.huaga.life_echo.feature.voice.RecorderStopResult
|
||||
import com.huaga.life_echo.feature.voice.RecordingCoordinator
|
||||
import com.huaga.life_echo.network.ApiService
|
||||
import com.huaga.life_echo.network.WebSocketMessage
|
||||
import com.huaga.life_echo.network.models.ChapterDto
|
||||
import com.huaga.life_echo.network.models.ConversationListItemDto
|
||||
import com.huaga.life_echo.network.models.CreateConversationResponse
|
||||
import com.huaga.life_echo.network.models.MessageDto
|
||||
import com.huaga.life_echo.network.models.TasksStatusDto
|
||||
@@ -155,22 +155,22 @@ class CreateMemoryViewModelRecordingCoordinatorTest {
|
||||
context: Context,
|
||||
recordingCoordinator: RecordingCoordinator,
|
||||
): CreateMemoryViewModel {
|
||||
val apiService = ApiService()
|
||||
val conversationApi = NoOpConversationApiPort()
|
||||
return CreateMemoryViewModel(
|
||||
conversationRepository = ConversationRepository(
|
||||
conversationDao = FakeConversationDao(),
|
||||
segmentDao = FakeConversationSegmentDao(),
|
||||
apiService = apiService,
|
||||
conversationApi = conversationApi,
|
||||
),
|
||||
chapterRepository = ChapterRepository(
|
||||
chapterDao = FakeChapterDao(),
|
||||
),
|
||||
messageRepository = MessageRepository(
|
||||
messageDao = FakeMessageDao(),
|
||||
apiService = apiService,
|
||||
conversationApi = conversationApi,
|
||||
),
|
||||
context = context,
|
||||
conversationApi = NoOpConversationApiPort(),
|
||||
conversationApi = conversationApi,
|
||||
conversationRealtime = NoOpConversationRealtimePort(),
|
||||
recordingCoordinator = recordingCoordinator,
|
||||
tokenInitializer = {},
|
||||
@@ -287,6 +287,10 @@ class CreateMemoryViewModelRecordingCoordinatorTest {
|
||||
private class NoOpConversationApiPort : ConversationApiPort {
|
||||
override suspend fun createConversation(): Result<CreateConversationResponse> =
|
||||
Result.failure(Exception("no-op"))
|
||||
override suspend fun getConversationList(): Result<List<ConversationListItemDto>> =
|
||||
Result.success(emptyList())
|
||||
override suspend fun deleteConversation(conversationId: String): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
override suspend fun getMessages(conversationId: String): Result<List<MessageDto>> =
|
||||
Result.success(emptyList())
|
||||
override suspend fun getTasksStatus(): Result<TasksStatusDto> =
|
||||
|
||||
@@ -15,8 +15,8 @@ import com.huaga.life_echo.data.repository.MessageRepository
|
||||
import com.huaga.life_echo.feature.conversation.ports.AudioSegmentRequest
|
||||
import com.huaga.life_echo.feature.conversation.ports.ConversationApiPort
|
||||
import com.huaga.life_echo.feature.conversation.ports.ConversationRealtimePort
|
||||
import com.huaga.life_echo.network.ApiService
|
||||
import com.huaga.life_echo.network.WebSocketMessage
|
||||
import com.huaga.life_echo.network.models.ConversationListItemDto
|
||||
import com.huaga.life_echo.network.models.ChapterDto
|
||||
import com.huaga.life_echo.network.models.CreateConversationResponse
|
||||
import com.huaga.life_echo.network.models.MessageDto
|
||||
@@ -82,22 +82,22 @@ class CreateMemoryViewModelWarmupTest {
|
||||
context: Context,
|
||||
realtime: ConversationRealtimePort = FakeConversationRealtimePort(),
|
||||
): CreateMemoryViewModel {
|
||||
val apiService = ApiService()
|
||||
val conversationApi = FakeConversationApiPort()
|
||||
return CreateMemoryViewModel(
|
||||
conversationRepository = ConversationRepository(
|
||||
conversationDao = FakeConversationDao(),
|
||||
segmentDao = FakeConversationSegmentDao(),
|
||||
apiService = apiService,
|
||||
conversationApi = conversationApi,
|
||||
),
|
||||
chapterRepository = ChapterRepository(
|
||||
chapterDao = FakeChapterDao(),
|
||||
),
|
||||
messageRepository = MessageRepository(
|
||||
messageDao = FakeMessageDao(),
|
||||
apiService = apiService,
|
||||
conversationApi = conversationApi,
|
||||
),
|
||||
context = context,
|
||||
conversationApi = FakeConversationApiPort(),
|
||||
conversationApi = conversationApi,
|
||||
conversationRealtime = realtime,
|
||||
tokenInitializer = {},
|
||||
)
|
||||
@@ -150,6 +150,10 @@ class CreateMemoryViewModelWarmupTest {
|
||||
private class FakeConversationApiPort : ConversationApiPort {
|
||||
override suspend fun createConversation(): Result<CreateConversationResponse> =
|
||||
Result.failure(Exception("no-op"))
|
||||
override suspend fun getConversationList(): Result<List<ConversationListItemDto>> =
|
||||
Result.success(emptyList())
|
||||
override suspend fun deleteConversation(conversationId: String): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
override suspend fun getMessages(conversationId: String): Result<List<MessageDto>> =
|
||||
Result.failure(Exception("test-offline"))
|
||||
override suspend fun getTasksStatus(): Result<TasksStatusDto> =
|
||||
|
||||
Reference in New Issue
Block a user