Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: remove conversation from folder [WPB-14630] #3230

Merged
merged 4 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ internal interface ConversationFolderRepository {
suspend fun fetchConversationFolders(): Either<CoreFailure, Unit>
suspend fun addConversationToFolder(conversationId: QualifiedID, folderId: String): Either<CoreFailure, Unit>
suspend fun removeConversationFromFolder(conversationId: QualifiedID, folderId: String): Either<CoreFailure, Unit>
suspend fun removeFolder(folderId: String): Either<CoreFailure, Unit>
suspend fun syncConversationFoldersFromLocal(): Either<CoreFailure, Unit>
suspend fun observeFolders(): Flow<Either<CoreFailure, List<ConversationFolder>>>
}
Expand Down Expand Up @@ -143,6 +144,10 @@ internal class ConversationFolderDataSource internal constructor(
}
}

override suspend fun removeFolder(folderId: String): Either<CoreFailure, Unit> = wrapStorageRequest {
conversationFolderDAO.removeFolder(folderId)
}

override suspend fun syncConversationFoldersFromLocal(): Either<CoreFailure, Unit> {
kaliumLogger.withFeatureId(CONVERSATIONS_FOLDERS).v("Syncing conversation folders from local")
return wrapStorageRequest { conversationFolderDAO.getFoldersWithConversations().map { it.toModel() } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ import com.wire.kalium.logic.feature.conversation.folder.ObserveUserFoldersUseCa
import com.wire.kalium.logic.feature.conversation.folder.ObserveUserFoldersUseCaseImpl
import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFavoritesUseCase
import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFavoritesUseCaseImpl
import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFolderUseCase
import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFolderUseCaseImpl
import com.wire.kalium.logic.feature.conversation.guestroomlink.CanCreatePasswordProtectedLinksUseCase
import com.wire.kalium.logic.feature.conversation.guestroomlink.GenerateGuestRoomLinkUseCase
import com.wire.kalium.logic.feature.conversation.guestroomlink.GenerateGuestRoomLinkUseCaseImpl
Expand Down Expand Up @@ -390,4 +392,6 @@ class ConversationScope internal constructor(
get() = ObserveUserFoldersUseCaseImpl(conversationFolderRepository)
val moveConversationToFolder: MoveConversationToFolderUseCase
get() = MoveConversationToFolderUseCaseImpl(conversationFolderRepository)
val removeConversationFromFolder: RemoveConversationFromFolderUseCase
get() = RemoveConversationFromFolderUseCaseImpl(conversationFolderRepository)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Wire
* Copyright (C) 2024 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/

package com.wire.kalium.logic.feature.conversation.folder

import com.wire.kalium.logic.CoreFailure
import com.wire.kalium.logic.data.conversation.folders.ConversationFolderRepository
import com.wire.kalium.logic.data.id.ConversationId
import com.wire.kalium.logic.functional.Either
import com.wire.kalium.logic.functional.flatMap
import com.wire.kalium.logic.functional.fold
import com.wire.kalium.util.KaliumDispatcher
import com.wire.kalium.util.KaliumDispatcherImpl
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext

/**
* This use case will remove a conversation from the selected folder and if the folder is empty, it will remove the folder.
*/
interface RemoveConversationFromFolderUseCase {
/**
* @param conversationId the id of the conversation
* @param folderId the id of the folder
* @return the [Result] indicating a successful operation, otherwise a [CoreFailure]
*/
suspend operator fun invoke(conversationId: ConversationId, folderId: String): Result

sealed interface Result {
data object Success : Result
data class Failure(val cause: CoreFailure) : Result
}
}

internal class RemoveConversationFromFolderUseCaseImpl(
private val conversationFolderRepository: ConversationFolderRepository,
private val dispatchers: KaliumDispatcher = KaliumDispatcherImpl
) : RemoveConversationFromFolderUseCase {
override suspend fun invoke(
conversationId: ConversationId,
folderId: String
): RemoveConversationFromFolderUseCase.Result = withContext(dispatchers.io) {
conversationFolderRepository.removeConversationFromFolder(conversationId, folderId)
.flatMap {
if (conversationFolderRepository.observeConversationsFromFolder(folderId).first().isEmpty()) {
conversationFolderRepository.removeFolder(folderId)
} else {
Either.Right(Unit)
}
}
.flatMap {
conversationFolderRepository.syncConversationFoldersFromLocal()
}
.fold({
RemoveConversationFromFolderUseCase.Result.Failure(it)
}, {
RemoveConversationFromFolderUseCase.Result.Success
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,20 @@ class ConversationFolderRepositoryTest {
coVerify { arrangement.conversationFolderDAO.getFoldersWithConversations() }.wasInvoked()
}

@Test
fun givenValidFolderIdWhenRemovingFolderThenShouldRemoveSuccessfully() = runTest {
// given
val folderId = "folder1"
val arrangement = Arrangement().withSuccessfulFolderRemoval()

// when
val result = arrangement.repository.removeFolder(folderId)

// then
result.shouldSucceed()
coVerify { arrangement.conversationFolderDAO.removeFolder(eq(folderId)) }.wasInvoked()
}

private class Arrangement {

@Mock
Expand Down Expand Up @@ -278,5 +292,10 @@ class ConversationFolderRepositoryTest {
coEvery { conversationFolderDAO.removeConversationFromFolder(any(), any()) }.returns(Unit)
return this
}

suspend fun withSuccessfulFolderRemoval(): Arrangement {
coEvery { conversationFolderDAO.removeFolder(any()) }.returns(Unit)
return this
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/*
* Wire
* Copyright (C) 2024 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/
package com.wire.kalium.logic.feature.conversation.folder

import com.wire.kalium.logic.CoreFailure
import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents
import com.wire.kalium.logic.data.conversation.folders.ConversationFolderRepository
import com.wire.kalium.logic.data.id.ConversationId
import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFolderUseCase.Result
import com.wire.kalium.logic.framework.TestConversationDetails
import com.wire.kalium.logic.functional.Either
import io.mockative.Mock
import io.mockative.coEvery
import io.mockative.coVerify
import io.mockative.mock
import io.mockative.once
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertIs

class RemoveConversationFromFolderUseCaseTest {

@Test
fun givenValidConversationAndFolder_WhenRemoveAndSyncSuccessful_ThenReturnSuccess() = runTest {
val testConversationId = ConversationId("conversation-value", "conversation-domain")
val testFolderId = "test-folder-id"

val (arrangement, removeConversationUseCase) = Arrangement()
.withRemoveConversationFromFolder(testConversationId, testFolderId, Either.Right(Unit))
.withObserveConversationsFromFolder(testFolderId, flowOf(emptyList()))
.withRemoveFolder(testFolderId, Either.Right(Unit))
.withSyncFolders(Either.Right(Unit))
.arrange()

val result = removeConversationUseCase(testConversationId, testFolderId)

assertIs<Result.Success>(result)

coVerify {
arrangement.conversationFolderRepository.removeConversationFromFolder(testConversationId, testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.observeConversationsFromFolder(testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.removeFolder(testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.syncConversationFoldersFromLocal()
}.wasInvoked(exactly = once)
}

@Test
fun givenFolderNotEmpty_WhenRemoveAndSyncSuccessful_ThenReturnSuccessWithoutFolderRemoval() = runTest {
val testConversationId = ConversationId("conversation-value", "conversation-domain")
val testFolderId = "test-folder-id"

val (arrangement, removeConversationUseCase) = Arrangement()
.withRemoveConversationFromFolder(testConversationId, testFolderId, Either.Right(Unit))
.withObserveConversationsFromFolder(
testFolderId,
flowOf(listOf(ConversationDetailsWithEvents(TestConversationDetails.CONVERSATION_GROUP)))
)
.withSyncFolders(Either.Right(Unit))
.arrange()

val result = removeConversationUseCase(testConversationId, testFolderId)

assertIs<Result.Success>(result)

coVerify {
arrangement.conversationFolderRepository.removeConversationFromFolder(testConversationId, testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.observeConversationsFromFolder(testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.removeFolder(testFolderId)
}.wasNotInvoked()

coVerify {
arrangement.conversationFolderRepository.syncConversationFoldersFromLocal()
}.wasInvoked(exactly = once)
}

@Test
fun givenErrorDuringFolderRemoval_WhenObservedEmpty_ThenReturnFailure() = runTest {
val testConversationId = ConversationId("conversation-value", "conversation-domain")
val testFolderId = "test-folder-id"

val (arrangement, removeConversationUseCase) = Arrangement()
.withRemoveConversationFromFolder(testConversationId, testFolderId, Either.Right(Unit))
.withObserveConversationsFromFolder(testFolderId, flowOf(emptyList()))
.withRemoveFolder(testFolderId, Either.Left(CoreFailure.Unknown(null)))
.arrange()

val result = removeConversationUseCase(testConversationId, testFolderId)

assertIs<Result.Failure>(result)

coVerify {
arrangement.conversationFolderRepository.removeConversationFromFolder(testConversationId, testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.observeConversationsFromFolder(testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.removeFolder(testFolderId)
}.wasInvoked(exactly = once)
}

private class Arrangement {
@Mock
val conversationFolderRepository = mock(ConversationFolderRepository::class)

private val removeConversationFromFolderUseCase = RemoveConversationFromFolderUseCaseImpl(
conversationFolderRepository
)

suspend fun withRemoveConversationFromFolder(
conversationId: ConversationId,
folderId: String,
either: Either<CoreFailure, Unit>
) = apply {
coEvery {
conversationFolderRepository.removeConversationFromFolder(conversationId, folderId)
}.returns(either)
}

suspend fun withObserveConversationsFromFolder(
folderId: String,
flow: Flow<List<ConversationDetailsWithEvents>>
) = apply {
coEvery {
conversationFolderRepository.observeConversationsFromFolder(folderId)
}.returns(flow)
}

suspend fun withRemoveFolder(
folderId: String,
either: Either<CoreFailure, Unit>
) = apply {
coEvery {
conversationFolderRepository.removeFolder(folderId)
}.returns(either)
}

suspend fun withSyncFolders(either: Either<CoreFailure, Unit>) = apply {
coEvery {
conversationFolderRepository.syncConversationFoldersFromLocal()
}.returns(either)
}

fun arrange(block: Arrangement.() -> Unit = { }) = apply(block).let { this to removeConversationFromFolderUseCase }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ CREATE TABLE LabeledConversation (
conversation_id TEXT AS QualifiedIDEntity NOT NULL,
folder_id TEXT NOT NULL,

FOREIGN KEY (conversation_id) REFERENCES Conversation(qualified_id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (folder_id) REFERENCES ConversationFolder(id) ON DELETE CASCADE ON UPDATE CASCADE,

PRIMARY KEY (folder_id, conversation_id)
Expand Down Expand Up @@ -68,3 +67,6 @@ DELETE FROM LabeledConversation;

clearFolders:
DELETE FROM ConversationFolder;

deleteFolder:
DELETE FROM ConversationFolder WHERE id = ?;
15 changes: 15 additions & 0 deletions persistence/src/commonMain/db_user/migrations/96.sqm
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
CREATE TABLE LabeledConversation_New (
conversation_id TEXT AS QualifiedIDEntity NOT NULL,
folder_id TEXT NOT NULL,

FOREIGN KEY (folder_id) REFERENCES ConversationFolder(id) ON DELETE CASCADE ON UPDATE CASCADE,

PRIMARY KEY (folder_id, conversation_id)
);

INSERT INTO LabeledConversation_New (conversation_id, folder_id)
SELECT conversation_id, folder_id FROM LabeledConversation;

DROP TABLE LabeledConversation;

ALTER TABLE LabeledConversation_New RENAME TO LabeledConversation;
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ interface ConversationFolderDAO {
suspend fun addConversationToFolder(conversationId: QualifiedIDEntity, folderId: String)
suspend fun removeConversationFromFolder(conversationId: QualifiedIDEntity, folderId: String)
suspend fun observeFolders(): Flow<List<ConversationFolderEntity>>
suspend fun removeFolder(folderId: String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import app.cash.sqldelight.coroutines.asFlow
import com.wire.kalium.persistence.ConversationFolder
import com.wire.kalium.persistence.ConversationFoldersQueries
import com.wire.kalium.persistence.GetAllFoldersWithConversations
import com.wire.kalium.persistence.LabeledConversation
import com.wire.kalium.persistence.dao.QualifiedIDEntity
import com.wire.kalium.persistence.dao.conversation.ConversationDetailsWithEventsEntity
import com.wire.kalium.persistence.dao.conversation.ConversationDetailsWithEventsMapper
Expand All @@ -45,6 +46,10 @@ class ConversationFolderDAOImpl internal constructor(
.flowOn(coroutineContext)
}

override suspend fun removeFolder(folderId: String) = withContext(coroutineContext) {
conversationFoldersQueries.deleteFolder(folderId)
}

override suspend fun getFoldersWithConversations(): List<FolderWithConversationsEntity> = withContext(coroutineContext) {
val labeledConversationList = conversationFoldersQueries.getAllFoldersWithConversations().executeAsList().map(::toEntity)

Expand All @@ -69,6 +74,11 @@ class ConversationFolderDAOImpl internal constructor(
conversationId = row.conversation_id
)

private fun toEntity(row: LabeledConversation) = ConversationLabelEntity(
folderId = row.folder_id,
conversationId = row.conversation_id
)

private fun toEntity(row: ConversationFolder) = ConversationFolderEntity(
id = row.id,
name = row.name,
Expand Down
Loading
Loading