diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupInteraction.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupInteraction.kt new file mode 100644 index 00000000000..3a84a919612 --- /dev/null +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupInteraction.kt @@ -0,0 +1,370 @@ +/* + * Wire + * Copyright (C) 2026 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.android.tests.core.criticalFlows + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import backendUtils.BackendClient +import backendUtils.team.TeamHelper +import backendUtils.team.TeamRoles +import com.wire.android.tests.core.BaseUiTest +import com.wire.android.tests.core.pages.AllPages +import com.wire.android.tests.support.UiAutomatorSetup +import com.wire.android.tests.support.tags.Category +import com.wire.android.tests.support.tags.TestCaseId +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.test.inject +import service.TestServiceHelper +import uiautomatorutils.UiWaitUtils.iSeeSystemMessage +import uiautomatorutils.UiWaitUtils.waitUntilToastIsDisplayed +import user.usermanager.ClientUserManager +import user.utils.ClientUser +import kotlin.getValue + +@RunWith(AndroidJUnit4::class) +class GroupInteraction : BaseUiTest() { + private val pages: AllPages by inject() + private lateinit var device: UiDevice + private lateinit var context: Context + private lateinit var backendClient: BackendClient + private lateinit var teamHelper: TeamHelper + private lateinit var testServiceHelper: TestServiceHelper + private lateinit var teamOwnerA: ClientUser + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().context + device = UiAutomatorSetup.start(UiAutomatorSetup.APP_INTERNAL) + backendClient = BackendClient.loadBackend("STAGING") + teamHelper = TeamHelper() + testServiceHelper = TestServiceHelper(teamHelper.usersManager) + } + + @After + fun tearDown() { + cleanupCreatedUsers(backendClient, teamHelper.usersManager) + } + + @Suppress("CyclomaticComplexMethod", "LongMethod") + @TestCaseId("TC-8601") + @Category("criticalFlow") + @Test + fun givenTeamOwnerWithGroupConversationAndBot_whenValidatingReactionsAndInteractions_thenFlowSucceeds() { + step("There is TeamOwnerA with team Bots") { + teamHelper.usersManager.createTeamOwnerByAlias( + "user1Name", + "Bots", + "en_US", + true, + backendClient, + context + ) + } + + step("TeamOwnerA adds Member1 and Member2 to team Bots with role Member") { + teamHelper.userXAddsUsersToTeam( + "user1Name", + "user2Name,user3Name", + "Bots", + TeamRoles.Member, + backendClient, + context, + true + ) + } + + step("There is TeamOwnerB with team ConnectedFriend") { + teamHelper.usersManager.createTeamOwnerByAlias( + "user4Name", + "ConnectedFriend", + "en_US", + true, + backendClient, + context + ) + } + + step("TeamOwnerA is connected to TeamOwnerB") { + testServiceHelper.userIsConnectedTo("user1Name", "user4Name") + } + + step("TeamOwnerA adds a new device Device1 with label Device1") { + testServiceHelper.addDevice("user1Name", null, "Device1") + } + + step("TeamOwnerA enables Poll Bot service for team Bots") { + testServiceHelper.userEnablesServiceForTeam("user1Name", "Poll Bot", "Bots") + } + + step("TeamOwnerA has group conversation BotsConversation with Member1, Member2, and TeamOwnerB in team Bots") { + testServiceHelper.userHasGroupConversationInTeam( + "user1Name", + "BotsConversation", + "user2Name,user3Name,user4Name", + "Bots" + ) + } + + step("TeamOwnerA adds Poll Bot to conversation BotsConversation") { + testServiceHelper.userAddsBotToConversation("user1Name", "Poll Bot", "BotsConversation") + } + + step("TeamOwnerA is me") { + teamOwnerA = teamHelper.usersManager.findUserBy("user1Name", ClientUserManager.FindBy.NAME_ALIAS) + } + + step("And I see welcome screen before login") { + pages.registrationPage.apply { + assertEmailWelcomePage() + } + } + + step("And I open staging deep link login flow") { + pages.loginPage.apply { + clickStagingDeepLink() + clickProceedButtonOnDeeplinkOverlay() + } + } + + step("And I login as TeamOwnerA") { + pages.loginPage.apply { + enterTeamOwnerLoggingEmail(teamOwnerA.email ?: "") + clickLoginButton() + enterTeamOwnerLoggingPassword(teamOwnerA.password ?: "") + clickLoginButton() + } + } + + step("And I complete post-login permission and privacy prompts") { + pages.registrationPage.apply { + waitUntilLoginFlowIsCompleted() + clickAllowNotificationButton() + clickDeclineShareDataAlert() + } + } + + step("Then I tap on conversation name BotsConversation in conversation list") { + pages.conversationListPage.apply { + clickGroupConversation("BotsConversation") + } + } + + step("And I see group conversation BotsConversation is in foreground") { + pages.conversationViewPage.apply { + assertConversationScreenVisible() + } + } + + step("Then I see a banner informing me that Guests and apps are present in the conversation view") { + pages.conversationViewPage.apply { + assertGuestsAndAppsBannerVisible() + } + } + + step("When Member1 sends message Hello fellow members to group conversation BotsConversation") { + testServiceHelper.apply { + addDevice("user2Name", null, "Device2") + userSendMessageToConversation( + "user2Name", + "Hello fellow members", + "Device2", + "BotsConversation", + false + ) + } + } + + step("Then I see the message Hello fellow members in current conversation") { + pages.conversationViewPage.apply { + assertReceivedMessageIsVisibleInCurrentConversation("Hello fellow members") + } + } + + step("When I long tap on the message Hello fellow members in current conversation") { + pages.conversationViewPage.apply { + longPressOnMessage("Hello fellow members") + } + } + + step("And I see reactions options") { + pages.conversationViewPage.apply { + assertTextMessageReactionOptionsVisible() + } + } + + step("And I tap on heart reaction icon") { + pages.conversationViewPage.apply { + tapReactionIcon("\u2764\uFE0F") // ❤️ + } + } + + step("Then I see a heart reaction from 1 user as reaction to Member1 message") { + pages.conversationViewPage.apply { + assertReactionAndUserCountVisible("\u2764\uFE0F", 1) // ❤️ + } + } + + step("When I type the message Hello Team Members into text input field and send it") { + pages.conversationViewPage.apply { + typeMessageInInputField("Hello Team Members") + clickSendButton() + } + } + + step("Then I see the message Hello Team Members in current conversation") { + pages.conversationViewPage.apply { + assertSentMessageIsVisibleInCurrentConversation("Hello Team Members") + } + } + + step("When TeamOwner toggles thumbs up reaction on the recent message from BotsConversation via Device1") { + testServiceHelper.userTogglesReactionOnLatestMessage( + "user1Name", + "BotsConversation", + "Device1", + "\uD83D\uDC4D" // 👍 + ) + } + + step("Then I see a thumbs up reaction from 1 user as reaction to TeamOwnerA message") { + pages.conversationViewPage.apply { + assertReactionAndUserCountVisible("\uD83D\uDC4D", 1) // 👍 + } + } + + step("When I tap on group conversation title BotsConversation to open group details") { + pages.conversationViewPage.apply { + clickOnGroupConversationDetails("BotsConversation") + } + } + + step("And I tap on Participants tab") { + pages.groupConversationDetailsPage.apply { + tapOnParticipantsTab() + } + } + + step("Then I see Member2 in participants list") { + val member2 = teamHelper.usersManager.findUserBy("user3Name", ClientUserManager.FindBy.NAME_ALIAS) + pages.groupConversationDetailsPage.apply { + assertUsernameIsAddedToParticipantsList(member2.name ?: "") + } + } + + step("When I close the group conversation details through X icon") { + pages.groupConversationDetailsPage.apply { + tapCloseButtonOnGroupConversationDetailsPage() + } + } + + step("And TeamOwnerA removes TeamOwnerB from group conversation BotsConversation") { + testServiceHelper.userRemovesUserFromGroupConversation( + "user1Name", + "user4Name", + "BotsConversation" + ) + } + + step("Then I see system message You removed TeamOwnerB from the conversation in conversation view") { + val teamOwnerB = teamHelper.usersManager.findUserBy("user4Name", ClientUserManager.FindBy.NAME_ALIAS) + iSeeSystemMessage("You removed ${teamOwnerB.name ?: ""} from the conversation") + } + + step("When I tap on group conversation title BotsConversation to open group details") { + pages.conversationViewPage.apply { + clickOnGroupConversationDetails("BotsConversation") + } + } + + step("And I see group details page") { + pages.groupConversationDetailsPage.apply { + assertGroupDetailsPageVisible() + } + } + + step("And I tap on Participants tab") { + pages.groupConversationDetailsPage.apply { + tapOnParticipantsTab() + } + } + + step("And I see user Poll Bot in participants list") { + pages.groupConversationDetailsPage.apply { + assertUsernameIsAddedToParticipantsList("Poll Bot") + } + } + + step("And I tap on user Poll Bot in participants list") { + pages.groupConversationDetailsPage.apply { + tapUserInParticipantsList("Poll Bot") + } + } + + step("Then I see Remove From Conversation button for App") { + pages.groupConversationDetailsPage.apply { + assertRemoveFromConversationButtonForAppVisible() + } + } + + step("When I tap Remove From Conversation button and see toast message App removed from Conversation") { + pages.groupConversationDetailsPage.apply { + tapRemoveFromConversationButton() + waitUntilToastIsDisplayed("App removed from conversation") + } + } + + step("Then I do not see Remove From Conversation button again") { + pages.groupConversationDetailsPage.apply { + assertRemoveFromConversationButtonNotVisible() + } + } + + step("And I now see Add to Conversation button") { + pages.groupConversationDetailsPage.apply { + assertAddToConversationButtonVisible() + } + } + + step("When I tap back button") { + pages.groupConversationDetailsPage.apply { + tapBackButton() + } + } + + step("Then I do not see user Poll Bot in participants list") { + pages.groupConversationDetailsPage.apply { + assertUserIsNotInParticipantsList("Poll Bot") + } + } + + step("When I close the group conversation details through X icon") { + pages.groupConversationDetailsPage.apply { + tapCloseButtonOnGroupConversationDetailsPage() + } + } + + step("Then I see system message You removed Poll Bot from the conversation in conversation view") { + iSeeSystemMessage("You removed Poll Bot from the conversation") + } + } +} diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt index 34d3d7d444a..b4d374b5404 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt @@ -224,7 +224,7 @@ class SSODeviceBackup : BaseUiTest() { step("Start SSO login again using SSO code") { pages.registrationPage.apply { - assertEmailWelcomePage() + assertEmailWelcomePage(timeout = UiWaitUtils.VERY_LONG_TIMEOUT) } pages.loginPage.apply { clickStagingDeepLink() diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt index 3075e203b65..cc9994e5e95 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt @@ -75,6 +75,7 @@ data class ConversationViewPage(private val device: UiDevice) { private val selfDeletingMessageLabel = UiSelectorParams(description = " Self-deleting message") private val pingButton = UiSelectorParams(description = "Ping") private val pingButtonOnModal = UiSelectorParams(text = "Ping") + private val guestsAndAppsBanner = UiSelectorParams(textContains = "Guests and apps are present") private val mlsUpgradeMessageSelectors = listOf( UiSelectorParams(textContains = "This conversation now uses the new Messaging"), @@ -155,6 +156,15 @@ data class ConversationViewPage(private val device: UiDevice) { return this } + fun longPressOnMessage(message: String): ConversationViewPage { + val messageElement = UiWaitUtils.waitElement(UiSelectorParams(text = message)) + val center = messageElement.visibleCenter + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + .swipe(center.x, center.y, center.x, center.y, 120) + + return this + } + fun assertBottomSheetIsVisible(): ConversationViewPage { val bottomSheet = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) .findObject(UiSelector().className("android.view.View").instance(4)) @@ -182,6 +192,40 @@ data class ConversationViewPage(private val device: UiDevice) { return this } + fun assertTextMessageReactionOptionsVisible(): ConversationViewPage { + val expectedOptions = listOf( + "REACTIONS", + "Message Details", + "Copy text", + "Reply", + "Delete" + ) + + expectedOptions.forEach { expectedText -> + val element = UiWaitUtils.waitElement(UiSelectorParams(text = expectedText)) + assertTrue("Option with text '$expectedText' is not visible", !element.visibleBounds.isEmpty) + assertEquals(expectedText, element.text, "Option text does not match expected") + } + + return this + } + + fun tapReactionIcon(reaction: String): ConversationViewPage { + val reactionIcon = UiWaitUtils.waitElement(UiSelectorParams(text = reaction)) + reactionIcon.click() + return this + } + + fun assertReactionAndUserCountVisible(reaction: String, userCount: Int): ConversationViewPage { + val reactionElement = UiWaitUtils.waitElement(UiSelectorParams(text = reaction)) + val countElement = UiWaitUtils.waitElement(UiSelectorParams(text = userCount.toString())) + + assertTrue("Reaction '$reaction' is not visible", !reactionElement.visibleBounds.isEmpty) + assertTrue("Reaction count '$userCount' is not visible", !countElement.visibleBounds.isEmpty) + + return this + } + fun tapDownloadButton(): ConversationViewPage { UiWaitUtils.waitElement(downloadButton).click() return this @@ -467,6 +511,16 @@ data class ConversationViewPage(private val device: UiDevice) { return this } + fun assertGuestsAndAppsBannerVisible(): ConversationViewPage { + try { + UiWaitUtils.waitElement(guestsAndAppsBanner) + } catch (e: AssertionError) { + throw AssertionError("'Guests and apps are present' banner is not visible in conversation view", e) + } + + return this + } + fun click1On1ConversationDetails(userName: String): ConversationViewPage { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) val userName = device.findObject(conversationDetails1On1(userName)) diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/GroupConversationDetailsPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/GroupConversationDetailsPage.kt index 86db85d3cf2..c552e685702 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/GroupConversationDetailsPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/GroupConversationDetailsPage.kt @@ -20,6 +20,7 @@ package com.wire.android.tests.core.pages import androidx.test.uiautomator.UiDevice import uiautomatorutils.UiSelectorParams import uiautomatorutils.UiWaitUtils +import uiautomatorutils.UiWaitUtils.toBySelector data class GroupConversationDetailsPage(private val device: UiDevice) { @@ -37,6 +38,26 @@ data class GroupConversationDetailsPage(private val device: UiDevice) { private val closeButtonOnGroupConversationDetailsPage = UiSelectorParams(description = "Close conversation details") + private val conversationDetailsHeading = UiSelectorParams(text = "Conversation Details") + + private val removeFromConversationButton = UiSelectorParams(text = "Remove From Conversation") + + private val addToConversationButton = UiSelectorParams(text = "Add To Conversation") + + private fun textViewSelector(text: String) = UiSelectorParams( + className = "android.widget.TextView", + text = text + ) + + fun assertGroupDetailsPageVisible(): GroupConversationDetailsPage { + try { + UiWaitUtils.waitElement(conversationDetailsHeading) + } catch (e: AssertionError) { + throw AssertionError("Group details page is not visible.", e) + } + return this + } + fun tapShowMoreOptionsButton() { UiWaitUtils.waitElement(showMoreOptionsButton).click() } @@ -58,10 +79,7 @@ data class GroupConversationDetailsPage(private val device: UiDevice) { } fun assertUsernameInSuggestionsListIs(expectedHandle: String): GroupConversationDetailsPage { - val handleSelector = UiSelectorParams( - className = "android.widget.TextView", - text = expectedHandle - ) + val handleSelector = textViewSelector(expectedHandle) try { UiWaitUtils.waitElement(params = handleSelector) } catch (e: AssertionError) { @@ -74,10 +92,7 @@ data class GroupConversationDetailsPage(private val device: UiDevice) { } fun selectUserInSuggestionList(expectedHandle: String): GroupConversationDetailsPage { - val handleSelector = UiSelectorParams( - className = "android.widget.TextView", - text = expectedHandle - ) + val handleSelector = textViewSelector(expectedHandle) val handleTextView = try { UiWaitUtils.waitElement(params = handleSelector) @@ -98,10 +113,7 @@ data class GroupConversationDetailsPage(private val device: UiDevice) { } fun assertUsernameIsAddedToParticipantsList(expectedHandle: String): GroupConversationDetailsPage { - val handleSelector = UiSelectorParams( - className = "android.widget.TextView", - text = expectedHandle - ) + val handleSelector = textViewSelector(expectedHandle) try { UiWaitUtils.waitElement(params = handleSelector) } catch (e: AssertionError) { @@ -113,6 +125,49 @@ data class GroupConversationDetailsPage(private val device: UiDevice) { return this } + fun tapUserInParticipantsList(expectedHandle: String): GroupConversationDetailsPage { + UiWaitUtils.waitElement(textViewSelector(expectedHandle)).parent.click() + return this + } + + fun assertRemoveFromConversationButtonForAppVisible(): GroupConversationDetailsPage { + UiWaitUtils.waitElement(removeFromConversationButton) + return this + } + + fun tapRemoveFromConversationButton(): GroupConversationDetailsPage { + UiWaitUtils.waitElement(removeFromConversationButton).click() + return this + } + + fun assertRemoveFromConversationButtonNotVisible(): GroupConversationDetailsPage { + UiWaitUtils.waitUntilGoneOrThrow( + selector = removeFromConversationButton.toBySelector(), + timeout = UiWaitUtils.SHORT_TIMEOUT, + errorMessage = "Remove From Conversation button is still visible." + ) + return this + } + + fun assertAddToConversationButtonVisible(): GroupConversationDetailsPage { + UiWaitUtils.waitElement(addToConversationButton) + return this + } + + fun tapBackButton(): GroupConversationDetailsPage { + device.pressBack() + return this + } + + fun assertUserIsNotInParticipantsList(expectedHandle: String): GroupConversationDetailsPage { + UiWaitUtils.waitUntilGoneOrThrow( + selector = textViewSelector(expectedHandle).toBySelector(), + timeout = UiWaitUtils.SHORT_TIMEOUT, + errorMessage = "User '$expectedHandle' is still visible in participants list." + ) + return this + } + fun tapCloseButtonOnGroupConversationDetailsPage(): GroupConversationDetailsPage { UiWaitUtils.waitElement(closeButtonOnGroupConversationDetailsPage).click() return this diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt index def7b65ac7c..e43219ca351 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt @@ -67,8 +67,8 @@ class RegistrationPage(private val device: UiDevice) { private val agreeButton = UiSelectorParams(text = "Agree") private val conversationsPage = UiSelectorParams(text = "Conversations") - fun assertEmailWelcomePage(): RegistrationPage { - val element = UiWaitUtils.waitElement(welcomePage) + fun assertEmailWelcomePage(timeout: Duration = UiWaitUtils.DEFAULT_TIMEOUT): RegistrationPage { + val element = UiWaitUtils.waitElement(welcomePage, timeout = timeout) assertTrue("Expected 'Enter your email to start!' to be visible", !element.visibleBounds.isEmpty) return this } diff --git a/tests/testsSupport/src/main/backendUtils/BackendClient.kt b/tests/testsSupport/src/main/backendUtils/BackendClient.kt index 6cad3544b41..abfad067944 100644 --- a/tests/testsSupport/src/main/backendUtils/BackendClient.kt +++ b/tests/testsSupport/src/main/backendUtils/BackendClient.kt @@ -41,6 +41,7 @@ import network.RequestOptions import org.json.JSONArray import org.json.JSONObject import service.models.Connection +import service.models.Conversation import service.models.TeamMember import user.utils.AccessCookie import user.utils.AccessCredentials @@ -632,6 +633,105 @@ class BackendClient( return JSONObject(response.body) } + suspend fun switchServiceForTeam( + ownerOrAdminUser: ClientUser, + teamId: String, + providerId: String, + serviceId: String, + isEnabled: Boolean + ) { + val token = getAuthToken(ownerOrAdminUser) + val url = URI("teams/$teamId/services/whitelist".composeCompleteUrl()).toURL() + + val headers = defaultheaders.toMutableMap().apply { + put(AUTHORIZATION, "${token?.type} ${token?.value}") + put(accept, "*/*") + } + + val requestBody = JSONObject().apply { + put("id", serviceId) + put("provider", providerId) + put("whitelisted", isEnabled) + } + + NetworkBackendClient.sendJsonRequestWithCookies( + url = url, + method = "POST", + headers = headers, + body = requestBody.toString(), + options = RequestOptions( + accessToken = token, + expectedResponseCodes = NumberSequence.Array( + intArrayOf(HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_NO_CONTENT) + ) + ) + ) + } + + suspend fun addServiceToConversation(asUser: ClientUser, serviceName: String, conversation: Conversation) { + val teamId = conversation.teamId + ?: throw IllegalStateException("Conversation '${conversation.name}' has no team id.") + val service = getWhitelistedService(asUser, teamId, serviceName) + val token = getAuthToken(asUser) + val url = URI("conversations/${conversation.id}/bots".composeCompleteUrl()).toURL() + + val headers = defaultheaders.toMutableMap().apply { + put(AUTHORIZATION, "${token?.type} ${token?.value}") + } + + val requestBody = JSONObject().apply { + put("service", service.getString("id")) + put("provider", service.getString("provider")) + } + + try { + NetworkBackendClient.sendJsonRequestWithCookies( + url = url, + method = "POST", + headers = headers, + body = requestBody.toString(), + options = RequestOptions( + accessToken = token, + expectedResponseCodes = NumberSequence.Array( + intArrayOf(HttpURLConnection.HTTP_CREATED, HttpURLConnection.HTTP_NO_CONTENT) + ) + ) + ) + } catch (e: HttpRequestException) { + throw HttpRequestException( + "POST $url failed with HTTP ${e.returnCode}: ${e.message}", + e.returnCode + ) + } + } + + private suspend fun getWhitelistedService(asUser: ClientUser, teamId: String, serviceName: String): JSONObject { + val token = getAuthToken(asUser) + val url = URI( + "teams/$teamId/services/whitelisted?prefix=${Uri.encode(serviceName)}".composeCompleteUrl() + ).toURL() + + val headers = defaultheaders.toMutableMap().apply { + put(AUTHORIZATION, "${token?.type} ${token?.value}") + } + + val response = NetworkBackendClient.sendJsonRequestWithCookies( + url = url, + method = "GET", + headers = headers, + options = RequestOptions(accessToken = token) + ) + + val services = JSONObject(response.body).getJSONArray("services") + for (index in 0 until services.length()) { + val service = services.getJSONObject(index) + if (service.optString("name") == serviceName) { + return service + } + } + throw NoSuchElementException("Service '$serviceName' is not whitelisted for team '$teamId'.") + } + fun getUserNameByID(domain: String, id: String, user: ClientUser): String { val token = runBlocking { getAuthToken(user) } @@ -819,6 +919,15 @@ class BackendClient( updateConnections(asUser, ConnectionStatus.Pending, ConnectionStatus.Accepted, null) } + suspend fun acceptIncomingConnectionRequest(asUser: ClientUser, fromUser: ClientUser) { + updateConnections( + asUser, + ConnectionStatus.Pending, + ConnectionStatus.Accepted, + listOf(fromUser.id.orEmpty()) + ) + } + private suspend fun updateConnections( asUser: ClientUser, srcStatus: ConnectionStatus, diff --git a/tests/testsSupport/src/main/service/ConversationExtensions.kt b/tests/testsSupport/src/main/service/ConversationExtensions.kt index 70a4eb5529b..de5d9c81703 100644 --- a/tests/testsSupport/src/main/service/ConversationExtensions.kt +++ b/tests/testsSupport/src/main/service/ConversationExtensions.kt @@ -1,4 +1,4 @@ -@file:Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown", "MagicNumber") +@file:Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown", "MagicNumber", "TooManyFunctions") /* * Wire * Copyright (C) 2025 Wire Swiss GmbH @@ -26,6 +26,7 @@ import com.wire.android.testSupport.backendConnections.team.Team import com.wire.android.testSupport.service.TestService import kotlinx.coroutines.runBlocking import network.NetworkBackendClient +import network.NumberSequence import network.RequestOptions import org.json.JSONArray import org.json.JSONObject @@ -39,6 +40,8 @@ import util.generateQRCode import java.io.File import java.io.FileOutputStream import java.io.IOException +import java.net.HttpURLConnection +import java.net.URI import java.net.URL import java.time.Duration import java.util.regex.Matcher @@ -324,6 +327,32 @@ fun BackendClient.addUsersToGroupConversation( return JSONObject(response.body) } +fun BackendClient.removeUserFromGroupConversation( + asUser: ClientUser, + contact: ClientUser, + conversation: Conversation +) { + val contactDomain = BackendClient.loadBackend(contact.backendName.orEmpty()).domain + val url = "conversations/${conversation.qualifiedID.domain}/${conversation.id}/members/$contactDomain/${contact.id}" + .composeCompleteUrl() + val token = runBlocking { getAuthToken(asUser) } + val headers = defaultheaders.toMutableMap().apply { + put("Authorization", "${token?.type} ${token?.value}") + } + + NetworkBackendClient.sendJsonRequestWithCookies( + url = URI(url).toURL(), + method = "DELETE", + headers = headers, + options = RequestOptions( + accessToken = token, + expectedResponseCodes = NumberSequence.Array( + intArrayOf(HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_NO_CONTENT) + ) + ) + ) +} + fun BackendClient.getPersonalConversationByName(user: ClientUser, name: String): Conversation = getConversations(user).firstOrNull { conv -> conv.protocol == "mls" && diff --git a/tests/testsSupport/src/main/service/TestServiceHelper.kt b/tests/testsSupport/src/main/service/TestServiceHelper.kt index c813dfcc9de..763fdbe8159 100644 --- a/tests/testsSupport/src/main/service/TestServiceHelper.kt +++ b/tests/testsSupport/src/main/service/TestServiceHelper.kt @@ -27,9 +27,11 @@ import com.wire.android.testSupport.service.TestService import kotlinx.coroutines.runBlocking import network.HttpRequestException import service.enums.LegalHoldStatus +import service.enums.TeamService import service.models.Conversation import service.models.SendLocationParams import service.models.SendTextParams +import uiautomatorutils.UiWaitUtils import user.usermanager.ClientUserManager import user.utils.ClientUser import java.io.File @@ -38,6 +40,7 @@ import java.io.IOException import java.time.Duration import java.util.concurrent.Callable import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds class TestServiceHelper( private val usersManager: ClientUserManager @@ -339,6 +342,62 @@ class TestServiceHelper( } } + fun userIsConnectedTo(userFromNameAlias: String, usersToNameAliases: String) { + val userFrom = toClientUser(userFromNameAlias) + val fromBackend = backendFor(userFrom) + val usersTo = usersManager + .splitAliases(usersToNameAliases) + .map(this::toClientUser) + runBlocking { + usersTo.forEach { userTo -> + fromBackend.sendConnectionRequest(userFrom, userTo) + backendFor(userTo).acceptIncomingConnectionRequest(userTo, userFrom) + } + } + } + + fun userEnablesServiceForTeam(ownerOrAdminUserAlias: String, serviceName: String, teamName: String) { + userSwitchesServicesForTeam(ownerOrAdminUserAlias, true, serviceName, teamName) + } + + fun userSwitchesServicesForTeam( + ownerOrAdminUserAlias: String, + isEnabled: Boolean, + serviceNames: String, + teamName: String + ) { + val ownerOrAdminUser = toClientUser(ownerOrAdminUserAlias) + val backend = backendFor(ownerOrAdminUser) + runBlocking { + val team = backend.getTeamByName(ownerOrAdminUser, teamName) + serviceNames.split(",") + .map(String::trim) + .map(TeamService::fromName) + .forEach { service -> + backend.switchServiceForTeam( + ownerOrAdminUser, + team.id, + service.providerId, + service.serviceId, + isEnabled + ) + } + } + } + + // region Bots + + fun userAddsBotToConversation(userWhoAddsAlias: String, botToAdd: String, chatName: String) { + val userWhoAdds = toClientUser(userWhoAddsAlias) + val backend = backendFor(userWhoAdds) + val conversation = toConvoObj(userWhoAdds, chatName) + runBlocking { + backend.addServiceToConversation(userWhoAdds, botToAdd, conversation) + } + } + + // endregion Bots + fun addDevice( ownerAlias: String, verificationCode: String? = null, @@ -421,6 +480,21 @@ class TestServiceHelper( } } + fun userRemovesUserFromGroupConversation( + userWhoRemovesAlias: String, + userToRemoveAlias: String, + chatName: String + ) { + val userWhoRemoves = toClientUser(userWhoRemovesAlias) + val userToRemove = toClientUser(userToRemoveAlias) + val backend = backendFor(userWhoRemoves) + backend.removeUserFromGroupConversation( + userWhoRemoves, + userToRemove, + toConvoObj(userWhoRemoves, chatName) + ) + } + private fun Int.toBoolean(): Boolean { return this != 0 } @@ -505,6 +579,49 @@ class TestServiceHelper( ) } + fun userTogglesReactionOnLatestMessage( + senderAlias: String, + convoName: String, + deviceName: String, + reaction: String + ) { + val sender = toClientUser(senderAlias) + val conversation = toConvoObj(sender, convoName) + val convoId = conversation.qualifiedID.id + val convoDomain = conversation.qualifiedID.domain + val recentMessageId = getRecentMessageId(sender, deviceName, convoId, convoDomain) + + testServiceClient.toggleReaction( + sender, + deviceName, + convoId, + convoDomain, + recentMessageId, + reaction + ) + } + + private fun getRecentMessageId( + user: ClientUser, + deviceName: String?, + convoId: String, + convoDomain: String + ): String { + var messageIds = emptyList() + val hasMessages = UiWaitUtils.retryUntilTimeout( + timeout = UiWaitUtils.MEDIUM_TIMEOUT, + pollingInterval = 1.seconds + ) { + messageIds = testServiceClient.getMessageIds(user, deviceName, convoId, convoDomain) + messageIds.isNotEmpty() + } + + if (hasMessages) { + return messageIds.last() + } + throw IllegalStateException("The conversation contains no messages") + } + private fun resolveMessageTimeout( senderAlias: String, dstConvoName: String, diff --git a/tests/testsSupport/src/main/service/enums/TeamService.kt b/tests/testsSupport/src/main/service/enums/TeamService.kt new file mode 100644 index 00000000000..cb3b798af96 --- /dev/null +++ b/tests/testsSupport/src/main/service/enums/TeamService.kt @@ -0,0 +1,47 @@ +/* + * Wire + * Copyright (C) 2026 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 service.enums + +enum class TeamService( + val serviceName: String, + val providerId: String, + val serviceId: String +) { + ECHO( + "Echo", + "d64af9ae-e0c5-4ce6-b38a-02fd9363b54c", + "d693bd64-79ae-4970-ad12-4df49cfe4038" + ), + POLL_BOT( + "Poll Bot", + "d1e52fa0-46bc-46fa-acc1-95bd91735de1", + "40085205-4499-4cd7-a093-ca7d3c1d8b21" + ), + TRACKER( + "Tracker", + "d64af9ae-e0c5-4ce6-b38a-02fd9363b54c", + "7ba4aac9-1bbb-41bd-b782-b57157665157" + ); + + companion object { + fun fromName(serviceName: String): TeamService { + return entries.firstOrNull { it.serviceName == serviceName } + ?: throw IllegalArgumentException("No such service: $serviceName") + } + } +}