From 9d842c1d881feec4fd4e226c65c21cffba64143d Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 8 Mar 2026 22:34:41 +0300 Subject: [PATCH] android: add chat/realtime tests and update android checklist --- android/CHANGELOG.md | 6 + android/app/build.gradle.kts | 3 + .../data/chat/local/dao/ChatDaoTest.kt | 94 +++++++++ .../repository/NetworkChatRepositoryTest.kt | 187 ++++++++++++++++++ .../data/realtime/RealtimeEventParserTest.kt | 44 +++++ docs/android-checklist.md | 32 +-- 6 files changed, 350 insertions(+), 16 deletions(-) create mode 100644 android/app/src/test/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDaoTest.kt create mode 100644 android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt create mode 100644 android/app/src/test/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParserTest.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index f36eab4..2314b6f 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -51,3 +51,9 @@ - Added private chat presence display (`online` / `last seen recently` fallback). - Connected Chat List to ViewModel/use-cases with no business logic inside composables. - Added chat click navigation to placeholder `ChatScreen(chatId)`. + +### Step 9 - Tests and checklist updates +- Added unit test for chat cache-first sync strategy (`NetworkChatRepositoryTest`). +- Added unit test for realtime event parsing (`RealtimeEventParserTest`). +- Added DAO test (`ChatDaoTest`) using in-memory Room + Robolectric. +- Updated Android checklist status in `docs/android-checklist.md`. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b321e05..d39b509 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -92,6 +92,9 @@ dependencies { testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") testImplementation("androidx.datastore:datastore-preferences-core:1.1.1") + testImplementation("androidx.room:room-testing:2.6.1") + testImplementation("androidx.test:core:1.6.1") + testImplementation("org.robolectric:robolectric:4.13") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") debugImplementation("androidx.compose.ui:ui-tooling:1.7.6") diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDaoTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDaoTest.kt new file mode 100644 index 0000000..a18823c --- /dev/null +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDaoTest.kt @@ -0,0 +1,94 @@ +package ru.daemonlord.messenger.data.chat.local.dao + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.robolectric.RobolectricTestRunner +import org.junit.runner.RunWith +import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase +import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity +import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class ChatDaoTest { + + private lateinit var db: MessengerDatabase + private lateinit var dao: ChatDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + MessengerDatabase::class.java, + ).allowMainThreadQueries() + .build() + dao = db.chatDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun clearAndReplaceChats_replacesOnlySelectedArchiveScope() = runTest { + dao.upsertChats( + listOf( + chatEntity(id = 1, archived = false, pinned = false, title = "All chat"), + chatEntity(id = 2, archived = true, pinned = true, title = "Archived chat"), + ) + ) + + dao.clearAndReplaceChats( + archived = false, + chats = listOf(chatEntity(id = 3, archived = false, pinned = false, title = "New all chat")), + users = listOf(UserShortEntity(id = 99, displayName = "User", username = "user", avatarUrl = null)), + ) + + val allChats = dao.observeChats(archived = false).first() + val archivedChats = dao.observeChats(archived = true).first() + assertEquals(1, allChats.size) + assertEquals(3L, allChats.first().id) + assertEquals(1, archivedChats.size) + assertEquals(2L, archivedChats.first().id) + } + + private fun chatEntity( + id: Long, + archived: Boolean, + pinned: Boolean, + title: String, + ): ChatEntity { + return ChatEntity( + id = id, + publicId = "public-$id", + type = "private", + title = title, + displayTitle = title, + handle = null, + avatarUrl = null, + archived = archived, + pinned = pinned, + muted = false, + unreadCount = 0, + unreadMentionsCount = 0, + counterpartUserId = null, + counterpartName = null, + counterpartUsername = null, + counterpartAvatarUrl = null, + counterpartIsOnline = false, + counterpartLastSeenAt = null, + lastMessageText = "hi", + lastMessageType = "text", + lastMessageCreatedAt = "2026-03-08T10:00:00Z", + updatedSortAt = "2026-03-08T10:00:00Z", + ) + } +} diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt new file mode 100644 index 0000000..d32a616 --- /dev/null +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt @@ -0,0 +1,187 @@ +package ru.daemonlord.messenger.data.chat.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import ru.daemonlord.messenger.data.chat.api.ChatApiService +import ru.daemonlord.messenger.data.chat.dto.ChatReadDto +import ru.daemonlord.messenger.data.chat.local.dao.ChatDao +import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity +import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity +import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel + +@OptIn(ExperimentalCoroutinesApi::class) +class NetworkChatRepositoryTest { + + private val dispatcher = StandardTestDispatcher() + + @Test + fun observeChats_cacheFirstThenNetworkSync() = runTest(dispatcher) { + val cached = ChatEntity( + id = 1, + publicId = "cached-public", + type = "private", + title = null, + displayTitle = "Cached chat", + handle = null, + avatarUrl = null, + archived = false, + pinned = false, + muted = false, + unreadCount = 0, + unreadMentionsCount = 0, + counterpartUserId = null, + counterpartName = null, + counterpartUsername = null, + counterpartAvatarUrl = null, + counterpartIsOnline = false, + counterpartLastSeenAt = null, + lastMessageText = "Cached message", + lastMessageType = "text", + lastMessageCreatedAt = "2026-03-08T10:00:00Z", + updatedSortAt = "2026-03-08T10:00:00Z", + ) + + val fakeDao = FakeChatDao(initialChats = listOf(cached)) + val fakeApi = FakeChatApiService( + chats = listOf( + ChatReadDto( + id = 2, + publicId = "remote-public", + type = "private", + displayTitle = "Remote chat", + archived = false, + pinned = true, + muted = false, + unreadCount = 5, + unreadMentionsCount = 1, + lastMessageText = "Remote message", + lastMessageType = "text", + lastMessageCreatedAt = "2026-03-08T11:00:00Z", + createdAt = "2026-03-08T09:00:00Z", + ) + ) + ) + + val repository = NetworkChatRepository( + chatApiService = fakeApi, + chatDao = fakeDao, + ioDispatcher = dispatcher, + ) + + val emissions = mutableListOf>() + val collectJob = backgroundScope.launch(dispatcher) { + repository.observeChats(archived = false) + .take(2) + .toList() + .forEach { list -> emissions.add(list.map { it.displayTitle }) } + } + + advanceUntilIdle() + collectJob.join() + + assertEquals(listOf("Cached chat"), emissions[0]) + assertEquals(listOf("Remote chat"), emissions[1]) + assertTrue(fakeApi.requestedArchivedParams.contains(false)) + } + + private class FakeChatApiService( + private val chats: List, + ) : ChatApiService { + val requestedArchivedParams = mutableListOf() + + override suspend fun getChats(archived: Boolean): List { + requestedArchivedParams += archived + return chats + } + + override suspend fun getChatById(chatId: Long): ChatReadDto { + return chats.first() + } + } + + private class FakeChatDao( + initialChats: List, + ) : ChatDao { + private val chats = MutableStateFlow(initialChats) + + override fun observeChats(archived: Boolean): Flow> { + return chats.map { entities -> + entities.filter { it.archived == archived }.map { it.toLocalModel() } + } + } + + override suspend fun upsertChats(chats: List) { + val merged = this.chats.value.associateBy { it.id }.toMutableMap() + chats.forEach { merged[it.id] = it } + this.chats.value = merged.values.toList() + } + + override suspend fun upsertUsers(users: List) = Unit + + override suspend fun deleteChatsByArchived(archived: Boolean) { + chats.value = chats.value.filterNot { it.archived == archived } + } + + override suspend fun deleteChat(chatId: Long) { + chats.value = chats.value.filterNot { it.id == chatId } + } + + override suspend fun updatePresence(chatId: Long, isOnline: Boolean, lastSeenAt: String?) = Unit + + override suspend fun updateLastMessage( + chatId: Long, + lastMessageText: String?, + lastMessageType: String?, + lastMessageCreatedAt: String?, + updatedSortAt: String?, + ) = Unit + + override suspend fun incrementUnread(chatId: Long, incrementBy: Int) = Unit + + override suspend fun clearAndReplaceChats( + archived: Boolean, + chats: List, + users: List, + ) { + deleteChatsByArchived(archived = archived) + upsertChats(chats) + } + + private fun ChatEntity.toLocalModel(): ChatListLocalModel { + return ChatListLocalModel( + id = id, + publicId = publicId, + type = type, + title = title, + displayTitle = displayTitle, + handle = handle, + avatarUrl = avatarUrl, + archived = archived, + pinned = pinned, + muted = muted, + unreadCount = unreadCount, + unreadMentionsCount = unreadMentionsCount, + counterpartName = counterpartName, + counterpartUsername = counterpartUsername, + counterpartAvatarUrl = counterpartAvatarUrl, + counterpartIsOnline = counterpartIsOnline, + counterpartLastSeenAt = counterpartLastSeenAt, + lastMessageText = lastMessageText, + lastMessageType = lastMessageType, + lastMessageCreatedAt = lastMessageCreatedAt, + updatedSortAt = updatedSortAt, + ) + } + } +} diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParserTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParserTest.kt new file mode 100644 index 0000000..aaf8eff --- /dev/null +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParserTest.kt @@ -0,0 +1,44 @@ +package ru.daemonlord.messenger.data.realtime + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent + +class RealtimeEventParserTest { + + private val parser = RealtimeEventParser( + json = Json { ignoreUnknownKeys = true } + ) + + @Test + fun parseChatUpdated_returnsMappedEvent() { + val payload = """ + { + "event": "chat_updated", + "payload": { "chat_id": 42 }, + "timestamp": "2026-03-08T12:00:00Z" + } + """.trimIndent() + + val event = parser.parse(payload) + assertTrue(event is RealtimeEvent.ChatUpdated) + assertEquals(42L, (event as RealtimeEvent.ChatUpdated).chatId) + } + + @Test + fun parseChatDeleted_returnsMappedEvent() { + val payload = """ + { + "event": "chat_deleted", + "payload": { "chat_id": 77 }, + "timestamp": "2026-03-08T12:00:00Z" + } + """.trimIndent() + + val event = parser.parse(payload) + assertTrue(event is RealtimeEvent.ChatDeleted) + assertEquals(77L, (event as RealtimeEvent.ChatDeleted).chatId) + } +} diff --git a/docs/android-checklist.md b/docs/android-checklist.md index a619fa2..eb27652 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -1,32 +1,32 @@ # Android Checklist (Telegram-подобный клиент) ## 1. Базовая архитектура -- [ ] Kotlin + Jetpack Compose +- [x] Kotlin + Jetpack Compose - [ ] Модульность: `core`, `data`, `feature-*`, `app` -- [ ] DI (Hilt/Koin) -- [ ] MVI/MVVM + единый state/presenter слой -- [ ] Coroutines + Flow + structured concurrency +- [x] DI (Hilt/Koin) +- [x] MVI/MVVM + единый state/presenter слой +- [x] Coroutines + Flow + structured concurrency - [ ] Логирование (Timber/Logcat policy) - [ ] Crash reporting (Firebase Crashlytics/Sentry) ## 2. Сеть и API -- [ ] Retrofit/OkHttp + auth interceptor -- [ ] Авто-refresh JWT +- [x] Retrofit/OkHttp + auth interceptor +- [x] Авто-refresh JWT - [ ] Единая обработка ошибок API -- [ ] Realtime WebSocket слой (reconnect/backoff) -- [ ] Маппинг DTO -> Domain -> UI models +- [x] Realtime WebSocket слой (reconnect/backoff) +- [x] Маппинг DTO -> Domain -> UI models - [ ] Версионирование API и feature flags ## 3. Локальное хранение и sync - [ ] Room для чатов/сообщений/пользователей -- [ ] DataStore для настроек +- [x] DataStore для настроек - [ ] Кэш медиа (Coil/Exo cache) - [ ] Offline-first чтение истории - [ ] Очередь отложенных действий (send/edit/delete) - [ ] Конфликт-резолв и reconcile после reconnect ## 4. Авторизация и аккаунт -- [ ] Login/Register flow (email-first) +- [x] Login/Register flow (email-first) - [ ] Verify email экран/обработка deep link - [ ] Reset password flow - [ ] Sessions list + revoke one/all @@ -42,11 +42,11 @@ ## 6. Список чатов - [ ] Tabs/фильтры (all/private/group/channel/archive) -- [ ] Pinned chats -- [ ] Unread badge + mention badge `@` -- [ ] Muted badge -- [ ] Last message preview по типам медиа -- [ ] Online indicator в private чатах +- [x] Pinned chats +- [x] Unread badge + mention badge `@` +- [x] Muted badge +- [x] Last message preview по типам медиа +- [x] Online indicator в private чатах ## 7. Сообщения - [ ] Отправка текста @@ -108,7 +108,7 @@ - [ ] Privacy-safe logging (без токенов/PII) ## 15. Качество -- [ ] Unit tests (domain/data) +- [x] Unit tests (domain/data) - [ ] UI tests (Compose test) - [ ] Integration tests для auth/chat/realtime - [ ] Performance baseline (startup, scroll, media)