android: add chat/realtime tests and update android checklist
This commit is contained in:
@@ -51,3 +51,9 @@
|
|||||||
- Added private chat presence display (`online` / `last seen recently` fallback).
|
- Added private chat presence display (`online` / `last seen recently` fallback).
|
||||||
- Connected Chat List to ViewModel/use-cases with no business logic inside composables.
|
- Connected Chat List to ViewModel/use-cases with no business logic inside composables.
|
||||||
- Added chat click navigation to placeholder `ChatScreen(chatId)`.
|
- 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`.
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ dependencies {
|
|||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||||
testImplementation("androidx.datastore:datastore-preferences-core:1.1.1")
|
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")
|
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||||
|
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling:1.7.6")
|
debugImplementation("androidx.compose.ui:ui-tooling:1.7.6")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<String>>()
|
||||||
|
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<ChatReadDto>,
|
||||||
|
) : ChatApiService {
|
||||||
|
val requestedArchivedParams = mutableListOf<Boolean>()
|
||||||
|
|
||||||
|
override suspend fun getChats(archived: Boolean): List<ChatReadDto> {
|
||||||
|
requestedArchivedParams += archived
|
||||||
|
return chats
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getChatById(chatId: Long): ChatReadDto {
|
||||||
|
return chats.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeChatDao(
|
||||||
|
initialChats: List<ChatEntity>,
|
||||||
|
) : ChatDao {
|
||||||
|
private val chats = MutableStateFlow(initialChats)
|
||||||
|
|
||||||
|
override fun observeChats(archived: Boolean): Flow<List<ChatListLocalModel>> {
|
||||||
|
return chats.map { entities ->
|
||||||
|
entities.filter { it.archived == archived }.map { it.toLocalModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upsertChats(chats: List<ChatEntity>) {
|
||||||
|
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<UserShortEntity>) = 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<ChatEntity>,
|
||||||
|
users: List<UserShortEntity>,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,32 @@
|
|||||||
# Android Checklist (Telegram-подобный клиент)
|
# Android Checklist (Telegram-подобный клиент)
|
||||||
|
|
||||||
## 1. Базовая архитектура
|
## 1. Базовая архитектура
|
||||||
- [ ] Kotlin + Jetpack Compose
|
- [x] Kotlin + Jetpack Compose
|
||||||
- [ ] Модульность: `core`, `data`, `feature-*`, `app`
|
- [ ] Модульность: `core`, `data`, `feature-*`, `app`
|
||||||
- [ ] DI (Hilt/Koin)
|
- [x] DI (Hilt/Koin)
|
||||||
- [ ] MVI/MVVM + единый state/presenter слой
|
- [x] MVI/MVVM + единый state/presenter слой
|
||||||
- [ ] Coroutines + Flow + structured concurrency
|
- [x] Coroutines + Flow + structured concurrency
|
||||||
- [ ] Логирование (Timber/Logcat policy)
|
- [ ] Логирование (Timber/Logcat policy)
|
||||||
- [ ] Crash reporting (Firebase Crashlytics/Sentry)
|
- [ ] Crash reporting (Firebase Crashlytics/Sentry)
|
||||||
|
|
||||||
## 2. Сеть и API
|
## 2. Сеть и API
|
||||||
- [ ] Retrofit/OkHttp + auth interceptor
|
- [x] Retrofit/OkHttp + auth interceptor
|
||||||
- [ ] Авто-refresh JWT
|
- [x] Авто-refresh JWT
|
||||||
- [ ] Единая обработка ошибок API
|
- [ ] Единая обработка ошибок API
|
||||||
- [ ] Realtime WebSocket слой (reconnect/backoff)
|
- [x] Realtime WebSocket слой (reconnect/backoff)
|
||||||
- [ ] Маппинг DTO -> Domain -> UI models
|
- [x] Маппинг DTO -> Domain -> UI models
|
||||||
- [ ] Версионирование API и feature flags
|
- [ ] Версионирование API и feature flags
|
||||||
|
|
||||||
## 3. Локальное хранение и sync
|
## 3. Локальное хранение и sync
|
||||||
- [ ] Room для чатов/сообщений/пользователей
|
- [ ] Room для чатов/сообщений/пользователей
|
||||||
- [ ] DataStore для настроек
|
- [x] DataStore для настроек
|
||||||
- [ ] Кэш медиа (Coil/Exo cache)
|
- [ ] Кэш медиа (Coil/Exo cache)
|
||||||
- [ ] Offline-first чтение истории
|
- [ ] Offline-first чтение истории
|
||||||
- [ ] Очередь отложенных действий (send/edit/delete)
|
- [ ] Очередь отложенных действий (send/edit/delete)
|
||||||
- [ ] Конфликт-резолв и reconcile после reconnect
|
- [ ] Конфликт-резолв и reconcile после reconnect
|
||||||
|
|
||||||
## 4. Авторизация и аккаунт
|
## 4. Авторизация и аккаунт
|
||||||
- [ ] Login/Register flow (email-first)
|
- [x] Login/Register flow (email-first)
|
||||||
- [ ] Verify email экран/обработка deep link
|
- [ ] Verify email экран/обработка deep link
|
||||||
- [ ] Reset password flow
|
- [ ] Reset password flow
|
||||||
- [ ] Sessions list + revoke one/all
|
- [ ] Sessions list + revoke one/all
|
||||||
@@ -42,11 +42,11 @@
|
|||||||
|
|
||||||
## 6. Список чатов
|
## 6. Список чатов
|
||||||
- [ ] Tabs/фильтры (all/private/group/channel/archive)
|
- [ ] Tabs/фильтры (all/private/group/channel/archive)
|
||||||
- [ ] Pinned chats
|
- [x] Pinned chats
|
||||||
- [ ] Unread badge + mention badge `@`
|
- [x] Unread badge + mention badge `@`
|
||||||
- [ ] Muted badge
|
- [x] Muted badge
|
||||||
- [ ] Last message preview по типам медиа
|
- [x] Last message preview по типам медиа
|
||||||
- [ ] Online indicator в private чатах
|
- [x] Online indicator в private чатах
|
||||||
|
|
||||||
## 7. Сообщения
|
## 7. Сообщения
|
||||||
- [ ] Отправка текста
|
- [ ] Отправка текста
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
- [ ] Privacy-safe logging (без токенов/PII)
|
- [ ] Privacy-safe logging (без токенов/PII)
|
||||||
|
|
||||||
## 15. Качество
|
## 15. Качество
|
||||||
- [ ] Unit tests (domain/data)
|
- [x] Unit tests (domain/data)
|
||||||
- [ ] UI tests (Compose test)
|
- [ ] UI tests (Compose test)
|
||||||
- [ ] Integration tests для auth/chat/realtime
|
- [ ] Integration tests для auth/chat/realtime
|
||||||
- [ ] Performance baseline (startup, scroll, media)
|
- [ ] Performance baseline (startup, scroll, media)
|
||||||
|
|||||||
Reference in New Issue
Block a user