diff --git a/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt b/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt index 1b00d15..5bf9444 100644 --- a/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt +++ b/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt @@ -63,4 +63,15 @@ fun anImageMeta( fun aRoomState( roomOverview: RoomOverview = aRoomOverview(), events: List = listOf(aRoomMessageEvent()), -) = RoomState(roomOverview, events) \ No newline at end of file +) = RoomState(roomOverview, events) + +fun aRoomInvite( + from: RoomMember = aRoomMember(), + roomId: RoomId = aRoomId(), + inviteMeta: RoomInvite.InviteMeta = RoomInvite.InviteMeta.DirectMessage, +) = RoomInvite(from, roomId, inviteMeta) + +fun aTypingEvent( + roomId: RoomId = aRoomId(), + members: List = listOf(aRoomMember()) +) = Typing(roomId, members) \ No newline at end of file diff --git a/core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt b/core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt index 967c5b5..919a808 100644 --- a/core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt +++ b/core/src/main/kotlin/app/dapk/st/core/extensions/FlowExtensions.kt @@ -20,3 +20,26 @@ suspend fun Flow.firstOrNull(count: Int, predicate: suspend (T) -> Boolea return result } + +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R +): Flow { + return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> + @Suppress("UNCHECKED_CAST") + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + ) + } +} + diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryMergeWithLocalEchosUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryMergeWithLocalEchosUseCase.kt new file mode 100644 index 0000000..2f0d602 --- /dev/null +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryMergeWithLocalEchosUseCase.kt @@ -0,0 +1,53 @@ +package app.dapk.st.engine + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.common.asString +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.room.RoomService + +internal typealias DirectoryMergeWithLocalEchosUseCase = suspend (OverviewState, UserId, Map>) -> OverviewState + +internal class DirectoryMergeWithLocalEchosUseCaseImpl( + private val roomService: RoomService, +) : DirectoryMergeWithLocalEchosUseCase { + + override suspend fun invoke(overview: OverviewState, selfId: UserId, echos: Map>): OverviewState { + return when { + echos.isEmpty() -> overview + else -> overview.map { + when (val roomEchos = echos[it.roomId]) { + null -> it + else -> it.mergeWithLocalEchos( + member = roomService.findMember(it.roomId, selfId) ?: RoomMember( + selfId, + null, + avatarUrl = null, + ), + echos = roomEchos, + ) + } + } + } + } + + private fun RoomOverview.mergeWithLocalEchos(member: RoomMember, echos: List): RoomOverview { + val latestEcho = echos.maxByOrNull { it.timestampUtc } + return if (latestEcho != null && latestEcho.timestampUtc > (this.lastMessage?.utcTimestamp ?: 0)) { + this.copy( + lastMessage = RoomOverview.LastMessage( + content = when (val message = latestEcho.message) { + is MessageService.Message.TextMessage -> message.content.body.asString() + is MessageService.Message.ImageMessage -> "\uD83D\uDCF7" + }, + utcTimestamp = latestEcho.timestampUtc, + author = member, + ) + ) + } else { + this + } + } + +} \ No newline at end of file diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt index b8033cd..27b8799 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt @@ -1,31 +1,35 @@ package app.dapk.st.engine -import app.dapk.st.matrix.common.* +import app.dapk.st.core.extensions.combine +import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.matrix.sync.SyncService import app.dapk.st.matrix.sync.SyncService.SyncEvent.Typing -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map internal class DirectoryUseCase( private val syncService: SyncService, private val messageService: MessageService, - private val roomService: RoomService, private val credentialsStore: CredentialsStore, private val roomStore: RoomStore, + private val mergeLocalEchosUseCase: DirectoryMergeWithLocalEchosUseCase, ) { fun state(): Flow { - return flow { emit(credentialsStore.credentials()!!.userId) }.flatMapMerge { userId -> + return flow { emit(credentialsStore.credentials()!!.userId) }.flatMapConcat { userId -> combine( - overviewDatasource(), + syncService.startSyncing(), + syncService.overview().map { it.map { it.engine() } }, messageService.localEchos(), roomStore.observeUnreadCountById(), syncService.events(), roomStore.observeMuted(), - ) { overviewState, localEchos, unread, events, muted -> - overviewState.mergeWithLocalEchos(localEchos, userId).map { roomOverview -> + ) { _, overviewState, localEchos, unread, events, muted -> + mergeLocalEchosUseCase.invoke(overviewState, userId, localEchos).map { roomOverview -> DirectoryItem( overview = roomOverview, unreadCount = UnreadCount(unread[roomOverview.roomId] ?: 0), @@ -36,50 +40,4 @@ internal class DirectoryUseCase( } } } - - private fun overviewDatasource() = combine( - syncService.startSyncing(), - syncService.overview().map { it.map { it.engine() } } - ) { _, overview -> overview }.filterNotNull() - - private suspend fun OverviewState.mergeWithLocalEchos(localEchos: Map>, userId: UserId): OverviewState { - return when { - localEchos.isEmpty() -> this - else -> this.map { - when (val roomEchos = localEchos[it.roomId]) { - null -> it - else -> it.mergeWithLocalEchos( - member = roomService.findMember(it.roomId, userId) ?: RoomMember( - userId, - null, - avatarUrl = null, - ), - echos = roomEchos, - ) - } - } - } - } - - private fun RoomOverview.mergeWithLocalEchos(member: RoomMember, echos: List): RoomOverview { - val latestEcho = echos.maxByOrNull { it.timestampUtc } - return if (latestEcho != null && latestEcho.timestampUtc > (this.lastMessage?.utcTimestamp ?: 0)) { - this.copy( - lastMessage = RoomOverview.LastMessage( - content = when (val message = latestEcho.message) { - is MessageService.Message.TextMessage -> message.content.body.asString() - is MessageService.Message.ImageMessage -> "\uD83D\uDCF7" - }, - utcTimestamp = latestEcho.timestampUtc, - author = member, - ) - ) - } else { - this - } - } - } - - - diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/InviteUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/InviteUseCase.kt index d688bdf..e903473 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/InviteUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/InviteUseCase.kt @@ -2,7 +2,6 @@ package app.dapk.st.engine import app.dapk.st.matrix.sync.SyncService import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map class InviteUseCase( @@ -14,6 +13,6 @@ class InviteUseCase( private fun invitesDatasource() = combine( syncService.startSyncing(), syncService.invites().map { it.map { it.engine() } } - ) { _, invites -> invites }.filterNotNull() + ) { _, invites -> invites } } \ No newline at end of file diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt index 6239ad3..93003d1 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt @@ -3,6 +3,7 @@ package app.dapk.st.engine import app.dapk.st.core.Base64 import app.dapk.st.core.BuildMeta import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.JobBag import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.matrix.MatrixClient import app.dapk.st.matrix.MatrixTaskRunner @@ -172,14 +173,14 @@ class MatrixEngine internal constructor( DirectoryUseCase( matrix.syncService(), matrix.messageService(), - matrix.roomService(), credentialsStore, - roomStore + roomStore, + DirectoryMergeWithLocalEchosUseCaseImpl(matrix.roomService()), ) } val timelineUseCase = unsafeLazy { val matrix = lazyMatrix.value - val mergeWithLocalEchosUseCase = MergeWithLocalEchosUseCaseImpl(LocalEchoMapper(MetaMapper())) + val mergeWithLocalEchosUseCase = TimelineMergeWithLocalEchosUseCaseImpl(LocalEchoMapper(MetaMapper())) val timeline = TimelineUseCaseImpl(matrix.syncService(), matrix.messageService(), matrix.roomService(), mergeWithLocalEchosUseCase) ReadMarkingTimeline(roomStore, credentialsStore, timeline, matrix.roomService()) } @@ -190,7 +191,16 @@ class MatrixEngine internal constructor( } val mediaDecrypter = unsafeLazy { MatrixMediaDecrypter(base64) } - val pushHandler = unsafeLazy { MatrixPushHandler(backgroundScheduler, credentialsStore, lazyMatrix.value.syncService(), roomStore) } + val pushHandler = unsafeLazy { + MatrixPushHandler( + backgroundScheduler, + credentialsStore, + lazyMatrix.value.syncService(), + roomStore, + coroutineDispatchers, + JobBag(), + ) + } val invitesUseCase = unsafeLazy { InviteUseCase(lazyMatrix.value.syncService()) } diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixPushHandler.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixPushHandler.kt index f9cac91..17e0a47 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixPushHandler.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixPushHandler.kt @@ -1,6 +1,8 @@ package app.dapk.st.engine import app.dapk.st.core.AppLogTag +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.JobBag import app.dapk.st.core.log import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.EventId @@ -9,17 +11,20 @@ import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.message.BackgroundScheduler import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.matrix.sync.SyncService -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull -private var previousJob: Job? = null - -@OptIn(DelicateCoroutinesApi::class) class MatrixPushHandler( private val backgroundScheduler: BackgroundScheduler, private val credentialsStore: CredentialsStore, private val syncService: SyncService, private val roomStore: RoomStore, + private val dispatchers: CoroutineDispatchers, + private val jobBag: JobBag, ) : PushHandler { override fun onNewToken(payload: JsonString) { @@ -35,13 +40,12 @@ class MatrixPushHandler( override fun onMessageReceived(eventId: EventId?, roomId: RoomId?) { log(AppLogTag.PUSH, "push received") - previousJob?.cancel() - previousJob = GlobalScope.launch { + jobBag.replace(MatrixPushHandler::class, dispatchers.global.launch { when (credentialsStore.credentials()) { null -> log(AppLogTag.PUSH, "push ignored due to missing api credentials") else -> doSync(roomId, eventId) } - } + }) } private suspend fun doSync(roomId: RoomId?, eventId: EventId?) { diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCase.kt index 8f91848..2b9c19c 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCase.kt @@ -4,11 +4,11 @@ import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.message.MessageService -internal typealias MergeWithLocalEchosUseCase = (RoomState, RoomMember, List) -> RoomState +internal typealias TimelineMergeWithLocalEchosUseCase = (RoomState, RoomMember, List) -> RoomState -internal class MergeWithLocalEchosUseCaseImpl( +internal class TimelineMergeWithLocalEchosUseCaseImpl( private val localEventMapper: LocalEchoMapper, -) : MergeWithLocalEchosUseCase { +) : TimelineMergeWithLocalEchosUseCase { override fun invoke(roomState: RoomState, member: RoomMember, echos: List): RoomState { val echosByEventId = echos.associateBy { it.eventId } diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ReadMarkingTimeline.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ReadMarkingTimeline.kt index 1a2480f..43199b5 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ReadMarkingTimeline.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ReadMarkingTimeline.kt @@ -5,9 +5,7 @@ import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.room.RoomService -import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomStore -import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* @@ -24,7 +22,7 @@ class ReadMarkingTimeline( val credentials = credentialsStore.credentials()!! roomStore.markRead(roomId) emit(credentials) - }.flatMapMerge { credentials -> + }.flatMapConcat { credentials -> var lastKnownReadEvent: EventId? = null observeTimelineUseCase.invoke(roomId, credentials.userId).distinctUntilChanged().onEach { state -> state.latestMessageEventFromOthers(self = credentials.userId)?.let { @@ -37,8 +35,9 @@ class ReadMarkingTimeline( } } - private suspend fun updateRoomReadStateAsync(latestReadEvent: EventId, state: MessengerPageState, isReadReceiptsDisabled: Boolean): Deferred<*> { - return coroutineScope { + @Suppress("DeferredResultUnused") + private suspend fun updateRoomReadStateAsync(latestReadEvent: EventId, state: MessengerPageState, isReadReceiptsDisabled: Boolean) { + coroutineScope { async { runCatching { roomService.markFullyRead(state.roomState.roomOverview.roomId, latestReadEvent, isPrivate = isReadReceiptsDisabled) @@ -48,10 +47,9 @@ class ReadMarkingTimeline( } } -} - -private fun MessengerPageState.latestMessageEventFromOthers(self: UserId) = this.roomState.events - .filterIsInstance() - .filterNot { it.author.id == self } - .firstOrNull() - ?.eventId + private fun MessengerPageState.latestMessageEventFromOthers(self: UserId) = this.roomState.events + .filterIsInstance() + .filterNot { it.author.id == self } + .firstOrNull() + ?.eventId +} \ No newline at end of file diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt index 1f7f930..219a93f 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt @@ -16,7 +16,7 @@ internal class TimelineUseCaseImpl( private val syncService: SyncService, private val messageService: MessageService, private val roomService: RoomService, - private val mergeWithLocalEchosUseCase: MergeWithLocalEchosUseCase + private val timelineMergeWithLocalEchosUseCase: TimelineMergeWithLocalEchosUseCase, ) : ObserveTimelineUseCase { override fun invoke(roomId: RoomId, userId: UserId): Flow { @@ -30,7 +30,7 @@ internal class TimelineUseCaseImpl( roomState = when { localEchos.isEmpty() -> roomState else -> { - mergeWithLocalEchosUseCase.invoke( + timelineMergeWithLocalEchosUseCase.invoke( roomState, roomService.findMember(roomId, userId) ?: userId.toFallbackMember(), localEchos, diff --git a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/DirectoryUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/DirectoryUseCaseTest.kt new file mode 100644 index 0000000..081000e --- /dev/null +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/DirectoryUseCaseTest.kt @@ -0,0 +1,115 @@ +package app.dapk.st.engine + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.sync.RoomOverview +import fake.FakeCredentialsStore +import fake.FakeRoomStore +import fake.FakeSyncService +import fixture.aMatrixRoomOverview +import fixture.aRoomMember +import fixture.aTypingEvent +import fixture.aUserCredentials +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.delegateReturn + +private val A_ROOM_OVERVIEW = aMatrixRoomOverview() +private const val AN_UNREAD_COUNT = 10 +private const val MUTED_ROOM = true +private val TYPING_MEMBERS = listOf(aRoomMember()) + +class DirectoryUseCaseTest { + + private val fakeSyncService = FakeSyncService() + private val fakeMessageService = FakeMessageService() + private val fakeCredentialsStore = FakeCredentialsStore() + private val fakeRoomStore = FakeRoomStore() + private val fakeMergeLocalEchosUseCase = FakeDirectoryMergeWithLocalEchosUseCase() + + private val useCase = DirectoryUseCase( + fakeSyncService, + fakeMessageService, + fakeCredentialsStore, + fakeRoomStore, + fakeMergeLocalEchosUseCase, + ) + + @Test + fun `given empty values, then reads default directory state and maps to engine`() = runTest { + givenEmitsDirectoryState( + A_ROOM_OVERVIEW, + unreadCount = null, + isMuted = false, + ) + + val result = useCase.state().first() + + result shouldBeEqualTo listOf( + DirectoryItem( + A_ROOM_OVERVIEW.engine(), + unreadCount = UnreadCount(0), + typing = null, + isMuted = false + ) + ) + } + + @Test + fun `given extra state, then reads directory state and maps to engine`() = runTest { + givenEmitsDirectoryState( + A_ROOM_OVERVIEW, + unreadCount = AN_UNREAD_COUNT, + isMuted = MUTED_ROOM, + typing = TYPING_MEMBERS + ) + + val result = useCase.state().first() + + result shouldBeEqualTo listOf( + DirectoryItem( + A_ROOM_OVERVIEW.engine(), + unreadCount = UnreadCount(AN_UNREAD_COUNT), + typing = aTypingEvent(A_ROOM_OVERVIEW.roomId, TYPING_MEMBERS), + isMuted = MUTED_ROOM + ) + ) + } + + private fun givenEmitsDirectoryState( + roomOverview: RoomOverview, + unreadCount: Int? = null, + isMuted: Boolean = false, + typing: List = emptyList(), + ) { + val userCredentials = aUserCredentials() + fakeCredentialsStore.givenCredentials().returns(userCredentials) + + val matrixOverviewState = listOf(roomOverview) + + fakeSyncService.givenStartsSyncing() + fakeSyncService.givenOverview().returns(flowOf(matrixOverviewState)) + fakeSyncService.givenEvents().returns(flowOf(if (typing.isEmpty()) emptyList() else listOf(aTypingSyncEvent(roomOverview.roomId, typing)))) + + fakeMessageService.givenEchos().returns(flowOf(emptyMap())) + fakeRoomStore.givenUnreadByCount().returns(flowOf(unreadCount?.let { mapOf(roomOverview.roomId to it) } ?: emptyMap())) + fakeRoomStore.givenMuted().returns(flowOf(if (isMuted) setOf(roomOverview.roomId) else emptySet())) + + val mappedOverview = roomOverview.engine() + val expectedOverviewState = listOf(mappedOverview) + fakeMergeLocalEchosUseCase.givenMergedEchos(expectedOverviewState, userCredentials.userId, emptyMap()).returns(expectedOverviewState) + } +} + +class FakeDirectoryMergeWithLocalEchosUseCase : DirectoryMergeWithLocalEchosUseCase by mockk() { + fun givenMergedEchos(overviewState: OverviewState, selfId: UserId, echos: Map>) = coEvery { + this@FakeDirectoryMergeWithLocalEchosUseCase.invoke(overviewState, selfId, echos) + }.delegateReturn() +} diff --git a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/InviteUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/InviteUseCaseTest.kt new file mode 100644 index 0000000..d534bfd --- /dev/null +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/InviteUseCaseTest.kt @@ -0,0 +1,39 @@ +package app.dapk.st.engine + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.sync.InviteMeta +import fake.FakeSyncService +import fixture.aRoomId +import fixture.aRoomMember +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import app.dapk.st.matrix.sync.RoomInvite as MatrixRoomInvite + +class InviteUseCaseTest { + + private val fakeSyncService = FakeSyncService() + private val useCase = InviteUseCase(fakeSyncService) + + @Test + fun `reads invites from sync service and maps to engine`() = runTest { + val aMatrixRoomInvite = aMatrixRoomInvite() + fakeSyncService.givenStartsSyncing() + fakeSyncService.givenInvites().returns(flowOf(listOf(aMatrixRoomInvite))) + + val result = useCase.invites().first() + + result shouldBeEqualTo listOf(aMatrixRoomInvite.engine()) + } + +} + +fun aMatrixRoomInvite( + from: RoomMember = aRoomMember(), + roomId: RoomId = aRoomId(), + inviteMeta: InviteMeta = InviteMeta.DirectMessage, +) = MatrixRoomInvite(from, roomId, inviteMeta) + diff --git a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCaseTest.kt index 4868445..637b761 100644 --- a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCaseTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCaseTest.kt @@ -17,7 +17,7 @@ private val ANOTHER_ROOM_MESSAGE_EVENT = A_ROOM_MESSAGE_EVENT.copy(eventId = anE class MergeWithLocalEchosUseCaseTest { private val fakeLocalEchoMapper = fake.FakeLocalEventMapper() - private val mergeWithLocalEchosUseCase = MergeWithLocalEchosUseCaseImpl(fakeLocalEchoMapper.instance) + private val mergeWithLocalEchosUseCase = TimelineMergeWithLocalEchosUseCaseImpl(fakeLocalEchoMapper.instance) @Test fun `given no local echos, when merging text message, then returns original state`() { diff --git a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ReadMarkingTimelineTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ReadMarkingTimelineTest.kt new file mode 100644 index 0000000..f70b33b --- /dev/null +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ReadMarkingTimelineTest.kt @@ -0,0 +1,66 @@ +package app.dapk.st.engine + +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.UserId +import fake.FakeCredentialsStore +import fake.FakeRoomStore +import fixture.* +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.delegateReturn +import test.runExpectTest + +private val A_ROOM_ID = aRoomId() +private val A_USER_CREDENTIALS = aUserCredentials() +private val A_ROOM_MESSAGE_FROM_OTHER_USER = aRoomMessageEvent(author = aRoomMember(id = aUserId("another-user"))) +private val A_ROOM_MESSAGE_FROM_SELF = aRoomMessageEvent(author = aRoomMember(id = A_USER_CREDENTIALS.userId)) +private val READ_RECEIPTS_ARE_DISABLED = true + +class ReadMarkingTimelineTest { + + private val fakeRoomStore = FakeRoomStore() + private val fakeCredentialsStore = FakeCredentialsStore().apply { givenCredentials().returns(A_USER_CREDENTIALS) } + private val fakeObserveTimelineUseCase = FakeObserveTimelineUseCase() + private val fakeRoomService = FakeRoomService() + + private val readMarkingTimeline = ReadMarkingTimeline( + fakeRoomStore, + fakeCredentialsStore, + fakeObserveTimelineUseCase, + fakeRoomService, + ) + + @Test + fun `given a message from self, when fetching, then only marks room as read on initial launch`() = runExpectTest { + fakeRoomStore.expectUnit(times = 1) { it.markRead(A_ROOM_ID) } + val messengerState = aMessengerState(roomState = aRoomState(events = listOf(A_ROOM_MESSAGE_FROM_SELF))) + fakeObserveTimelineUseCase.given(A_ROOM_ID, A_USER_CREDENTIALS.userId).returns(flowOf(messengerState)) + + val result = readMarkingTimeline.fetch(A_ROOM_ID, isReadReceiptsDisabled = READ_RECEIPTS_ARE_DISABLED).first() + + result shouldBeEqualTo messengerState + verifyExpects() + } + + @Test + fun `given a message from other user, when fetching, then marks room as read`() = runExpectTest { + fakeRoomStore.expectUnit(times = 2) { it.markRead(A_ROOM_ID) } + fakeRoomService.expectUnit { it.markFullyRead(A_ROOM_ID, A_ROOM_MESSAGE_FROM_OTHER_USER.eventId, isPrivate = READ_RECEIPTS_ARE_DISABLED) } + val messengerState = aMessengerState(roomState = aRoomState(events = listOf(A_ROOM_MESSAGE_FROM_OTHER_USER))) + fakeObserveTimelineUseCase.given(A_ROOM_ID, A_USER_CREDENTIALS.userId).returns(flowOf(messengerState)) + + val result = readMarkingTimeline.fetch(A_ROOM_ID, isReadReceiptsDisabled = READ_RECEIPTS_ARE_DISABLED).first() + + result shouldBeEqualTo messengerState + verifyExpects() + } + +} + +class FakeObserveTimelineUseCase : ObserveTimelineUseCase by mockk() { + fun given(roomId: RoomId, userId: UserId) = every { this@FakeObserveTimelineUseCase.invoke(roomId, userId) }.delegateReturn() +} diff --git a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/SendMessageUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/SendMessageUseCaseTest.kt new file mode 100644 index 0000000..f675c92 --- /dev/null +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/SendMessageUseCaseTest.kt @@ -0,0 +1,139 @@ +package app.dapk.st.engine + +import app.dapk.st.matrix.common.RichText +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.message.internal.ImageContentReader +import fake.FakeLocalIdFactory +import fixture.aRoomMember +import fixture.aRoomOverview +import fixture.anEventId +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import test.delegateReturn +import test.runExpectTest +import java.time.Clock + +private const val AN_IMAGE_URI = "" +private val AN_IMAGE_META = ImageContentReader.ImageContent( + height = 50, + width = 100, + size = 1000L, + fileName = "a file name", + mimeType = "image/png" +) +private const val A_CURRENT_TIME = 2000L +private const val A_LOCAL_ID = "a local id" +private val A_ROOM_OVERVIEW = aRoomOverview( + isEncrypted = true +) +private val A_REPLY = SendMessage.TextMessage.Reply( + aRoomMember(), + originalMessage = "", + anEventId(), + timestampUtc = 7000 +) +private const val A_TEXT_MESSAGE_CONTENT = "message content" + +class SendMessageUseCaseTest { + + private val fakeMessageService = FakeMessageService() + private val fakeLocalIdFactory = FakeLocalIdFactory().apply { givenCreate().returns(A_LOCAL_ID) } + private val fakeImageContentReader = FakeImageContentReader() + private val fakeClock = FakeClock().apply { givenMillis().returns(A_CURRENT_TIME) } + + private val useCase = SendMessageUseCase( + fakeMessageService, + fakeLocalIdFactory.instance, + fakeImageContentReader, + fakeClock.instance + ) + + @Test + fun `when sending image message, then schedules message`() = runExpectTest { + fakeImageContentReader.givenMeta(AN_IMAGE_URI).returns(AN_IMAGE_META) + val expectedImageMessage = createExpectedImageMessage(A_ROOM_OVERVIEW) + fakeMessageService.expect { it.scheduleMessage(expectedImageMessage) } + + useCase.send(SendMessage.ImageMessage(uri = AN_IMAGE_URI), A_ROOM_OVERVIEW) + + verifyExpects() + } + + @Test + fun `when sending text message, then schedules message`() = runExpectTest { + val expectedTextMessage = createExpectedTextMessage(A_ROOM_OVERVIEW, A_TEXT_MESSAGE_CONTENT, reply = null) + fakeMessageService.expect { it.scheduleMessage(expectedTextMessage) } + + useCase.send( + SendMessage.TextMessage( + content = A_TEXT_MESSAGE_CONTENT, + reply = null, + ), + A_ROOM_OVERVIEW + ) + + verifyExpects() + } + + @Test + fun `given a reply, when sending text message, then schedules message with reply`() = runExpectTest { + val expectedTextMessage = createExpectedTextMessage(A_ROOM_OVERVIEW, A_TEXT_MESSAGE_CONTENT, reply = A_REPLY) + fakeMessageService.expect { it.scheduleMessage(expectedTextMessage) } + + useCase.send( + SendMessage.TextMessage( + content = A_TEXT_MESSAGE_CONTENT, + reply = A_REPLY, + ), + A_ROOM_OVERVIEW + ) + + verifyExpects() + } + + + private fun createExpectedImageMessage(roomOverview: RoomOverview) = MessageService.Message.ImageMessage( + MessageService.Message.Content.ImageContent( + uri = AN_IMAGE_URI, + MessageService.Message.Content.ImageContent.Meta( + height = AN_IMAGE_META.height, + width = AN_IMAGE_META.width, + size = AN_IMAGE_META.size, + fileName = AN_IMAGE_META.fileName, + mimeType = AN_IMAGE_META.mimeType, + ) + ), + roomId = roomOverview.roomId, + sendEncrypted = roomOverview.isEncrypted, + localId = A_LOCAL_ID, + timestampUtc = A_CURRENT_TIME, + ) + + private fun createExpectedTextMessage(roomOverview: RoomOverview, messageContent: String, reply: SendMessage.TextMessage.Reply?) = + MessageService.Message.TextMessage( + content = MessageService.Message.Content.TextContent(RichText.of(messageContent)), + roomId = roomOverview.roomId, + sendEncrypted = roomOverview.isEncrypted, + localId = A_LOCAL_ID, + timestampUtc = A_CURRENT_TIME, + reply = reply?.let { + MessageService.Message.TextMessage.Reply( + author = it.author, + originalMessage = RichText.of(it.originalMessage), + replyContent = messageContent, + eventId = it.eventId, + timestampUtc = it.timestampUtc, + ) + } + ) +} + +class FakeImageContentReader : ImageContentReader by mockk() { + fun givenMeta(uri: String) = every { meta(uri) }.delegateReturn() +} + +class FakeClock { + val instance = mockk() + fun givenMillis() = every { instance.millis() }.delegateReturn() +} diff --git a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt index 2c553fe..38f950b 100644 --- a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt @@ -113,7 +113,7 @@ suspend fun Flow.test(scope: CoroutineScope) = FlowTestObserver(scope, th this.collect() } -class FakeMergeWithLocalEchosUseCase : MergeWithLocalEchosUseCase by mockk() { +class FakeMergeWithLocalEchosUseCase : TimelineMergeWithLocalEchosUseCase by mockk() { fun givenMerging(roomState: RoomState, roomMember: RoomMember, echos: List) = every { this@FakeMergeWithLocalEchosUseCase.invoke(roomState.engine(), roomMember, echos) }.delegateReturn() @@ -125,9 +125,8 @@ fun aTypingSyncEvent( ) = SyncService.SyncEvent.Typing(roomId, members) class FakeMessageService : MessageService by mockk() { - fun givenEchos(roomId: RoomId) = every { localEchos(roomId) }.delegateReturn() - + fun givenEchos() = every { localEchos() }.delegateReturn() } class FakeRoomService : RoomService by mockk() { @@ -137,7 +136,7 @@ class FakeRoomService : RoomService by mockk() { fun aMessengerState( self: UserId = aUserId(), - roomState: app.dapk.st.engine.RoomState, + roomState: app.dapk.st.engine.RoomState = aRoomState(), typing: Typing? = null, isMuted: Boolean = IS_ROOM_MUTED, ) = MessengerPageState(self, roomState, typing, isMuted) \ No newline at end of file diff --git a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt index 3b1cbb2..9a6bb1e 100644 --- a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt +++ b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt @@ -10,6 +10,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.Flow +import test.delegateReturn class FakeRoomStore : RoomStore by mockk() { @@ -34,8 +35,13 @@ class FakeRoomStore : RoomStore by mockk() { every { observeUnread() } returns unreadEvents } + fun givenUnreadEvents() = every { observeUnread() }.delegateReturn() + fun givenUnreadByCount() = every { observeUnreadCountById() }.delegateReturn() + fun givenNotMutedUnreadEvents(unreadEvents: Flow>>) { every { observeNotMutedUnread() } returns unreadEvents } + fun givenMuted() = every { observeMuted() }.delegateReturn() + } \ No newline at end of file diff --git a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeSyncService.kt b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeSyncService.kt index 2c1b0bf..d0acc50 100644 --- a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeSyncService.kt +++ b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeSyncService.kt @@ -4,17 +4,13 @@ import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.sync.SyncService import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import test.delegateReturn class FakeSyncService : SyncService by mockk() { - fun givenStartsSyncing() { - every { startSyncing() }.returns(flowOf(Unit)) - } - + fun givenStartsSyncing() = every { startSyncing() }.returns(flowOf(Unit)) fun givenRoom(roomId: RoomId) = every { room(roomId) }.delegateReturn() - - fun givenEvents(roomId: RoomId) = every { events(roomId) }.delegateReturn() - + fun givenEvents(roomId: RoomId? = null) = every { events(roomId) }.delegateReturn() + fun givenInvites() = every { invites() }.delegateReturn() + fun givenOverview() = every { overview() }.delegateReturn() }