diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index d34df5b..1835df3 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -91,3 +91,9 @@ - Added input composer with send flow, reply/edit modes, and inline action cancellation. - Added long-press actions (`reply`, `edit`, `delete`) for baseline message operations. - Added manual "load older" pagination trigger and chat back navigation integration. + +### Step 15 - Sprint A / 5) Message tests and docs +- Added unit tests for `NetworkMessageRepository` sync/send flows. +- Added DAO test for message scoped replace behavior in Room. +- Expanded realtime parser tests with rich `receive_message` mapping coverage. +- Updated `docs/android-checklist.md` for completed message-core items. diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/message/local/dao/MessageDaoTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/message/local/dao/MessageDaoTest.kt new file mode 100644 index 0000000..0ed3351 --- /dev/null +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/message/local/dao/MessageDaoTest.kt @@ -0,0 +1,79 @@ +package ru.daemonlord.messenger.data.message.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.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase +import ru.daemonlord.messenger.data.message.local.entity.MessageEntity + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class MessageDaoTest { + + private lateinit var db: MessengerDatabase + private lateinit var dao: MessageDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + MessengerDatabase::class.java, + ).allowMainThreadQueries() + .build() + dao = db.messageDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun clearAndReplaceMessages_replacesOnlyTargetChatMessages() = runTest { + dao.upsertMessages( + listOf( + message(id = 1, chatId = 10, text = "old-10"), + message(id = 2, chatId = 20, text = "keep-20"), + ) + ) + + dao.clearAndReplaceMessages( + chatId = 10, + messages = listOf(message(id = 3, chatId = 10, text = "new-10")), + attachments = emptyList(), + ) + + val chat10 = dao.observeRecentMessages(chatId = 10).first() + val chat20 = dao.observeRecentMessages(chatId = 20).first() + + assertEquals(1, chat10.size) + assertEquals(3L, chat10.first().id) + assertEquals(1, chat20.size) + assertEquals(2L, chat20.first().id) + } + + private fun message(id: Long, chatId: Long, text: String): MessageEntity { + return MessageEntity( + id = id, + chatId = chatId, + senderId = 1, + senderDisplayName = "User", + senderUsername = "user", + senderAvatarUrl = null, + replyToMessageId = null, + type = "text", + text = text, + status = null, + createdAt = "2026-03-08T12:00:00Z", + updatedAt = null, + ) + } +} diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepositoryTest.kt new file mode 100644 index 0000000..857fd35 --- /dev/null +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepositoryTest.kt @@ -0,0 +1,124 @@ +package ru.daemonlord.messenger.data.message.repository + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase +import ru.daemonlord.messenger.data.message.api.MessageApiService +import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto +import ru.daemonlord.messenger.data.message.dto.MessageReadDto +import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class NetworkMessageRepositoryTest { + + private lateinit var db: MessengerDatabase + private lateinit var fakeApi: FakeMessageApiService + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + MessengerDatabase::class.java, + ).allowMainThreadQueries().build() + fakeApi = FakeMessageApiService() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun syncRecentMessages_writesRemoteItemsToRoom() = runTest(dispatcher) { + fakeApi.messages = listOf( + MessageReadDto( + id = 101, + chatId = 5, + senderId = 7, + type = "text", + text = "hello", + createdAt = "2026-03-08T10:00:00Z", + updatedAt = null, + ) + ) + val repository = createRepository() + + val sync = repository.syncRecentMessages(chatId = 5) + assertTrue(sync is ru.daemonlord.messenger.domain.common.AppResult.Success) + + val items = repository.observeMessages(chatId = 5).first() + assertEquals(1, items.size) + assertEquals(101L, items.first().id) + assertEquals("hello", items.first().text) + } + + @Test + fun sendTextMessage_optimisticThenServerReplace() = runTest(dispatcher) { + fakeApi.sendResponse = MessageReadDto( + id = 202, + chatId = 8, + senderId = 1, + type = "text", + text = "from server", + createdAt = "2026-03-08T11:00:00Z", + updatedAt = null, + ) + val repository = createRepository() + + val result = repository.sendTextMessage(chatId = 8, text = "from client") + assertTrue(result is ru.daemonlord.messenger.domain.common.AppResult.Success) + + val items = repository.observeMessages(chatId = 8).first() + assertEquals(1, items.size) + assertEquals(202L, items.first().id) + assertEquals("from server", items.first().text) + } + + private fun createRepository(): NetworkMessageRepository { + return NetworkMessageRepository( + messageApiService = fakeApi, + messageDao = db.messageDao(), + chatDao = db.chatDao(), + ioDispatcher = dispatcher, + ) + } + + private class FakeMessageApiService : MessageApiService { + var messages: List = emptyList() + var sendResponse: MessageReadDto = MessageReadDto( + id = 1, + chatId = 1, + senderId = 1, + type = "text", + text = "ok", + createdAt = "2026-03-08T10:00:00Z", + ) + + override suspend fun getMessages(chatId: Long, limit: Int, beforeId: Long?): List { + return messages + } + + override suspend fun sendMessage(request: MessageCreateRequestDto): MessageReadDto { + return sendResponse + } + + override suspend fun editMessage(messageId: Long, request: MessageUpdateRequestDto): MessageReadDto { + return sendResponse.copy(id = messageId, text = request.text) + } + + override suspend fun deleteMessage(messageId: Long, forAll: Boolean) = Unit + } +} 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 index aaf8eff..fe8ab0f 100644 --- 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 @@ -41,4 +41,34 @@ class RealtimeEventParserTest { assertTrue(event is RealtimeEvent.ChatDeleted) assertEquals(77L, (event as RealtimeEvent.ChatDeleted).chatId) } + + @Test + fun parseReceiveMessage_returnsRichMappedEvent() { + val payload = """ + { + "event": "receive_message", + "payload": { + "chat_id": 88, + "message": { + "id": 9001, + "chat_id": 88, + "sender_id": 5, + "reply_to_message_id": 100, + "type": "text", + "text": "hi", + "created_at": "2026-03-08T12:00:00Z" + } + } + } + """.trimIndent() + + val event = parser.parse(payload) + assertTrue(event is RealtimeEvent.ReceiveMessage) + val mapped = event as RealtimeEvent.ReceiveMessage + assertEquals(88L, mapped.chatId) + assertEquals(9001L, mapped.messageId) + assertEquals(5L, mapped.senderId) + assertEquals(100L, mapped.replyToMessageId) + assertEquals("hi", mapped.text) + } } diff --git a/docs/android-checklist.md b/docs/android-checklist.md index eb27652..3d1684e 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -49,10 +49,10 @@ - [x] Online indicator в private чатах ## 7. Сообщения -- [ ] Отправка текста -- [ ] Reply/quote -- [ ] Edit (<=7 дней) -- [ ] Delete for me / for all (по правам) +- [x] Отправка текста +- [x] Reply/quote +- [x] Edit (<=7 дней) +- [x] Delete for me / for all (по правам) - [ ] Forward в 1+ чатов - [ ] Reactions - [ ] Delivery/read states