diff --git a/app-android/app/src/main/java/com/huaga/life_echo/app/AppContainer.kt b/app-android/app/src/main/java/com/huaga/life_echo/app/AppContainer.kt index b5a19cc..fc05477 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/app/AppContainer.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/app/AppContainer.kt @@ -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, ) } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt index 650a79e..b98c6e2 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt @@ -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> { return conversationDao.getAllConversations() @@ -27,7 +26,7 @@ class ConversationRepository( } suspend fun deleteConversation(id: String): Result { - 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( ) } } - diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/repository/MessageRepository.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/repository/MessageRepository.kt index 28c5818..bbd1c7a 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/data/repository/MessageRepository.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/repository/MessageRepository.kt @@ -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> { 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( diff --git a/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationApiAdapter.kt b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationApiAdapter.kt index d9b4197..d9eda3b 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationApiAdapter.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationApiAdapter.kt @@ -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> { + return apiService.getConversationList() + } + + override suspend fun deleteConversation(conversationId: String): Result { + return apiService.deleteConversation(conversationId) + } + override suspend fun getMessages(conversationId: String): Result> { return apiService.getMessages(conversationId) } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapter.kt b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapter.kt index a504b0d..1786d68 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapter.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapter.kt @@ -18,6 +18,7 @@ class ConversationRealtimeAdapter( override val state: StateFlow = _state.asStateFlow() override suspend fun prepare() { + webSocketClient.prepare() _state.value = ConversationRealtimePort.State.Preparing } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationApiPort.kt b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationApiPort.kt index dd7442e..39e89d5 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationApiPort.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/ports/ConversationApiPort.kt @@ -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 + suspend fun getConversationList(): Result> + suspend fun deleteConversation(conversationId: String): Result suspend fun getMessages(conversationId: String): Result> suspend fun getTasksStatus(): Result suspend fun getChapters(): Result> diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt index f7cd64f..3390cf3 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt @@ -46,6 +46,10 @@ open class WebSocketClient( private const val RECONNECT_DELAY_MS = 3000L private const val MAX_RECONNECT_ATTEMPTS = 5 } + + open fun prepare() { + transportProvider.warmUp() + } open suspend fun connect( conversationId: String, diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RealtimeTransportProvider.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RealtimeTransportProvider.kt index 6f6c8db..96453de 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RealtimeTransportProvider.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RealtimeTransportProvider.kt @@ -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 = _readiness.asStateFlow() @@ -17,15 +18,21 @@ class RealtimeTransportProvider( } fun getClient(): HttpClient { - return client ?: factory().also { - client = it - _readiness.value = NetworkReadiness.ClientReady + return synchronized(lock) { + client ?: factory().also { + client = it + _readiness.value = NetworkReadiness.ClientReady + } } } fun close() { - client?.close() - client = null - _readiness.value = NetworkReadiness.NotInitialized + val clientToClose = synchronized(lock) { + client.also { + client = null + _readiness.value = NetworkReadiness.NotInitialized + } + } + clientToClose?.close() } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RestClientProvider.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RestClientProvider.kt index 5587fbc..c9952d0 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RestClientProvider.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RestClientProvider.kt @@ -10,14 +10,17 @@ enum class RestClientProfile { AUTH, API } class RestClientProvider( private val factory: (RestClientProfile) -> HttpClient, ) { + private val lock = Any() private val clients = mutableMapOf() private val _readiness = MutableStateFlow(NetworkReadiness.NotInitialized) val readiness: StateFlow = _readiness.asStateFlow() fun getClient(profile: RestClientProfile): HttpClient { - return clients.getOrPut(profile) { - factory(profile).also { - _readiness.value = NetworkReadiness.ClientReady + return synchronized(lock) { + clients.getOrPut(profile) { + factory(profile).also { + _readiness.value = NetworkReadiness.ClientReady + } } } } @@ -27,8 +30,12 @@ class RestClientProvider( } fun close() { - clients.values.forEach { it.close() } - clients.clear() - _readiness.value = NetworkReadiness.NotInitialized + val clientsToClose = synchronized(lock) { + clients.values.toList().also { + clients.clear() + _readiness.value = NetworkReadiness.NotInitialized + } + } + clientsToClose.forEach { it.close() } } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/AuthViewModel.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/AuthViewModel.kt index 64a28b1..90f3714 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/AuthViewModel.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/AuthViewModel.kt @@ -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 = _isLoading.asStateFlow() @@ -48,7 +51,7 @@ class AuthViewModel(private val context: Context) : ViewModel() { val smsCountdown: StateFlow = _smsCountdown.asStateFlow() init { - TokenManager.initialize(context) + tokenInitializer(context) checkAuthStatus() } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt index fe32d6a..2659175 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt @@ -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( diff --git a/app-android/app/src/test/java/com/huaga/life_echo/data/repository/ConversationRepositoryTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/data/repository/ConversationRepositoryTest.kt new file mode 100644 index 0000000..be7a634 --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/data/repository/ConversationRepositoryTest.kt @@ -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() + val deletedConversations = mutableListOf() + + override fun getAllConversations() = flowOf(emptyList()) + 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()) + override suspend fun insertSegment(segment: ConversationSegment) = Unit + override suspend fun insertSegments(segments: List) = Unit + override suspend fun updateSegment(segment: ConversationSegment) = Unit + override suspend fun deleteSegment(segment: ConversationSegment) = Unit + } + + private class FakeConversationApiPort( + private val conversationListResult: Result> = + Result.success(emptyList()), + private val deleteConversationResult: Result = + Result.failure(Exception("not configured")), + ) : ConversationApiPort { + var getConversationListCalls: Int = 0 + private set + val deletedConversationIds = mutableListOf() + + override suspend fun createConversation(): Result = + Result.failure(Exception("not configured")) + + override suspend fun getMessages(conversationId: String): Result> = + Result.failure(Exception("not configured")) + + override suspend fun getTasksStatus(): Result = + Result.failure(Exception("not configured")) + + override suspend fun getChapters(): Result> = + Result.failure(Exception("not configured")) + + override suspend fun clearTasks(): Result = + Result.failure(Exception("not configured")) + + override suspend fun getConversationList(): Result> { + getConversationListCalls += 1 + return conversationListResult + } + + override suspend fun deleteConversation(conversationId: String): Result { + deletedConversationIds += conversationId + return deleteConversationResult + } + } +} diff --git a/app-android/app/src/test/java/com/huaga/life_echo/data/repository/MessageRepositoryTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/data/repository/MessageRepositoryTest.kt new file mode 100644 index 0000000..b070f49 --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/data/repository/MessageRepositoryTest.kt @@ -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>() + + override fun getMessagesByConversationId(conversationId: String) = flowOf(emptyList()) + override suspend fun getMessageById(id: String): Message? = null + override suspend fun insertMessage(message: Message) = Unit + override suspend fun insertMessages(messages: List) { + 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>, + ) : ConversationApiPort { + val requestedMessageConversationIds = mutableListOf() + + override suspend fun createConversation(): Result = + Result.failure(Exception("not configured")) + + override suspend fun getConversationList(): Result> = + Result.failure(Exception("not configured")) + + override suspend fun deleteConversation(conversationId: String): Result = + Result.failure(Exception("not configured")) + + override suspend fun getMessages(conversationId: String): Result> { + requestedMessageConversationIds += conversationId + return messagesResult + } + + override suspend fun getTasksStatus(): Result = + Result.failure(Exception("not configured")) + + override suspend fun getChapters(): Result> = + Result.failure(Exception("not configured")) + + override suspend fun clearTasks(): Result = + Result.failure(Exception("not configured")) + } +} diff --git a/app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapterTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapterTest.kt index 283ab15..b1639df 100644 --- a/app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapterTest.kt +++ b/app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/adapters/ConversationRealtimeAdapterTest.kt @@ -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, diff --git a/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/ProviderConcurrencyTestSupport.kt b/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/ProviderConcurrencyTestSupport.kt new file mode 100644 index 0000000..e6055e7 --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/ProviderConcurrencyTestSupport.kt @@ -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 { + val executor = Executors.newFixedThreadPool(parallelism) + val startGate = CountDownLatch(1) + return try { + val futures: List> = 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) +} diff --git a/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RealtimeTransportProviderTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RealtimeTransportProviderTest.kt index c0c0433..2f1e238 100644 --- a/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RealtimeTransportProviderTest.kt +++ b/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RealtimeTransportProviderTest.kt @@ -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() }) + } } diff --git a/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RestClientProviderTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RestClientProviderTest.kt index ac3580d..ea6cb40 100644 --- a/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RestClientProviderTest.kt +++ b/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RestClientProviderTest.kt @@ -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() }) + } } diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/AuthViewModelTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/AuthViewModelTest.kt new file mode 100644 index 0000000..c698c00 --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/AuthViewModelTest.kt @@ -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)) + } +} diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelRecordingCoordinatorTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelRecordingCoordinatorTest.kt index 66a5113..c1d1813 100644 --- a/app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelRecordingCoordinatorTest.kt +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelRecordingCoordinatorTest.kt @@ -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 = Result.failure(Exception("no-op")) + override suspend fun getConversationList(): Result> = + Result.success(emptyList()) + override suspend fun deleteConversation(conversationId: String): Result = + Result.success(Unit) override suspend fun getMessages(conversationId: String): Result> = Result.success(emptyList()) override suspend fun getTasksStatus(): Result = diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelWarmupTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelWarmupTest.kt index cb7186b..5af7307 100644 --- a/app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelWarmupTest.kt +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelWarmupTest.kt @@ -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 = Result.failure(Exception("no-op")) + override suspend fun getConversationList(): Result> = + Result.success(emptyList()) + override suspend fun deleteConversation(conversationId: String): Result = + Result.success(Unit) override suspend fun getMessages(conversationId: String): Result> = Result.failure(Exception("test-offline")) override suspend fun getTasksStatus(): Result =