From 3178699213624378f108f73b5fb582288f146a33 Mon Sep 17 00:00:00 2001 From: Iu Date: Fri, 16 Oct 2026 12:48:02 +0300 Subject: [PATCH] =?UTF-8?q?test(android):=20=D1=81=D0=BA=D0=B2=D0=BE=D0=B7?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=8D=D0=BC=D1=83=D0=BB=D1=8F=D1=82=D0=BE=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiveAlbumsE2ETest против локального сервера и двух аккаунтов: участник регистрируется на устройстве, владелец создаёт альбом и приглашает его, альбом появляется в списке без обновления, переименование меняет открытый экран, исключение убирает альбом и возвращает экран к списку. - E2E: хелперы invite/accept/rename/remove и получение id пользователя --- .../androidTest/java/com/galo/android/E2E.kt | 80 +++++++++ .../com/galo/android/LiveAlbumsE2ETest.kt | 153 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 android/app/src/androidTest/java/com/galo/android/LiveAlbumsE2ETest.kt diff --git a/android/app/src/androidTest/java/com/galo/android/E2E.kt b/android/app/src/androidTest/java/com/galo/android/E2E.kt index 5334098..a5fb5e3 100644 --- a/android/app/src/androidTest/java/com/galo/android/E2E.kt +++ b/android/app/src/androidTest/java/com/galo/android/E2E.kt @@ -78,6 +78,86 @@ object E2E { } } + /** Registers a fresh user and returns the access token alongside the user id. */ + fun registerWithId(): Pair { + val tokens = registerTokens() + return tokens.accessToken to userId(tokens.accessToken) + } + + /** The id of the user the token belongs to. */ + fun userId(token: String): String { + val request = Request.Builder() + .url("$BASE_URL/api/auth/me") + .header("Authorization", "Bearer $token") + .get() + .build() + client.newCall(request).execute().use { response -> + check(response.isSuccessful) { "me failed: HTTP ${response.code}" } + val user = GaloJson.decodeFromString( + response.body!!.string(), + ).user + return user.id + } + } + + /** Creates an album invite and returns its token. */ + fun invite(token: String, albumId: String, role: String = "viewer"): String { + val payload = """{"role":"$role"}""".toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$BASE_URL/api/albums/$albumId/invites") + .header("Authorization", "Bearer $token") + .post(payload) + .build() + client.newCall(request).execute().use { response -> + check(response.isSuccessful) { "invite failed: HTTP ${response.code}" } + val body = response.body!!.string() + return kotlinx.serialization.json.Json.parseToJsonElement(body) + .let { it as kotlinx.serialization.json.JsonObject } + .getValue("invite") + .let { it as kotlinx.serialization.json.JsonObject } + .getValue("token") + .let { it as kotlinx.serialization.json.JsonPrimitive } + .content + } + } + + /** Accepts an invite, joining the album as the invited user. */ + fun acceptInvite(token: String, inviteToken: String) { + val request = Request.Builder() + .url("$BASE_URL/api/invites/$inviteToken/accept") + .header("Authorization", "Bearer $token") + .post(ByteArray(0).toRequestBody(null)) + .build() + client.newCall(request).execute().use { response -> + check(response.isSuccessful) { "accept invite failed: HTTP ${response.code}" } + } + } + + /** Renames an album (drives a live `album.updated`). */ + fun renameAlbum(token: String, albumId: String, title: String) { + val payload = """{"title":"$title"}""".toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$BASE_URL/api/albums/$albumId") + .header("Authorization", "Bearer $token") + .patch(payload) + .build() + client.newCall(request).execute().use { response -> + check(response.isSuccessful) { "rename failed: HTTP ${response.code}" } + } + } + + /** Removes a member from an album (drives a personal `album.deleted`). */ + fun removeMember(token: String, albumId: String, userId: String) { + val request = Request.Builder() + .url("$BASE_URL/api/albums/$albumId/members/$userId") + .header("Authorization", "Bearer $token") + .delete() + .build() + client.newCall(request).execute().use { response -> + check(response.isSuccessful) { "remove member failed: HTTP ${response.code}" } + } + } + fun upload(token: String, albumId: String, filename: String, bytes: ByteArray, mime: String) { val multipart = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("files", filename, bytes.toRequestBody(mime.toMediaType())) diff --git a/android/app/src/androidTest/java/com/galo/android/LiveAlbumsE2ETest.kt b/android/app/src/androidTest/java/com/galo/android/LiveAlbumsE2ETest.kt new file mode 100644 index 0000000..6603bc1 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/LiveAlbumsE2ETest.kt @@ -0,0 +1,153 @@ +package com.galo.android + +import android.os.Build +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.galo.android.domain.media.MediaPermissions +import com.galo.android.ui.MainActivity +import com.galo.android.ui.TAG_ALL_FILES_PROMPT_DIALOG +import com.galo.android.ui.album.TAG_ALBUM_GRID +import com.galo.android.ui.albums.TAG_ALBUM_CARD_PREFIX +import com.galo.android.ui.auth.TAG_AUTH_EMAIL +import com.galo.android.ui.auth.TAG_AUTH_IDENTIFIER +import com.galo.android.ui.auth.TAG_AUTH_NAME +import com.galo.android.ui.auth.TAG_AUTH_PASSWORD +import com.galo.android.ui.auth.TAG_AUTH_SUBMIT +import com.galo.android.ui.setup.TAG_SETUP_CONNECT +import com.galo.android.ui.setup.TAG_SETUP_INSECURE +import com.galo.android.ui.setup.TAG_SETUP_URL +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.junit.runners.model.Statement + +/** + * The album list and an open album screen follow changes made on another account, + * delivered over the personal stream (`GET /api/sse/me`) - no manual refresh. + * + * Start the server with `HTTP_ADDR=:8088 ./bin/galo` and run: + * `./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.galo.android.LiveAlbumsE2ETest` + */ +@RunWith(AndroidJUnit4::class) +class LiveAlbumsE2ETest { + + private val mediaPermissionRule: TestRule = object : TestRule { + override fun apply(base: Statement, description: Description): Statement = object : Statement() { + override fun evaluate() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val pkg = instrumentation.targetContext.packageName + MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission -> + runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) } + } + base.evaluate() + } + } + } + + private val compose = createAndroidComposeRule() + + @get:Rule + val rules: TestRule = RuleChain.outerRule(mediaPermissionRule).around(compose) + + private fun app() = ApplicationProvider.getApplicationContext() + + private fun awaitTag(tag: String) { + compose.waitUntil(TIMEOUT) { + compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun awaitTagGone(tag: String) { + compose.waitUntil(TIMEOUT) { + compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty() + } + } + + private fun awaitText(text: String) { + compose.waitUntil(TIMEOUT) { + compose.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun awaitAlbumsScreen() { + compose.waitUntil(TIMEOUT) { + compose.onAllNodesWithText("Пока нет альбомов").fetchSemanticsNodes().isNotEmpty() || + compose.onAllNodesWithTag(com.galo.android.ui.albums.TAG_ALBUMS_GRID) + .fetchSemanticsNodes().isNotEmpty() + } + } + + @Test + fun listAndOpenAlbumFollowChangesFromAnotherAccount() { + val stamp = System.currentTimeMillis() + val email = "android-live-$stamp@example.com" + + // Onboarding as the member account. + compose.onNodeWithTag(TAG_SETUP_URL).performTextInput(E2E.BASE_URL) + compose.onNodeWithTag(TAG_SETUP_INSECURE).performClick() + compose.onNodeWithTag(TAG_SETUP_CONNECT).performClick() + + compose.waitUntil(TIMEOUT) { + compose.onAllNodesWithTag(TAG_AUTH_IDENTIFIER).fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithText("Создать аккаунт").performClick() + compose.waitUntil(TIMEOUT) { + compose.onAllNodesWithTag(TAG_AUTH_NAME).fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithTag(TAG_AUTH_NAME).performTextInput("Member") + compose.onNodeWithTag(TAG_AUTH_EMAIL).performTextInput(email) + compose.onNodeWithTag(TAG_AUTH_PASSWORD).performTextInput("secret123") + compose.onNodeWithTag(TAG_AUTH_SUBMIT).performClick() + + awaitAlbumsScreen() + if (compose.onAllNodesWithTag(TAG_ALL_FILES_PROMPT_DIALOG).fetchSemanticsNodes().isNotEmpty()) { + compose.onNodeWithText("Позже").performClick() + } + + val memberToken = app().container.tokenStore.accessToken() + ?: error("expected a stored access token after registration") + val memberId = E2E.userId(memberToken) + + // The owner creates an album and invites the member, who accepts. + val (ownerToken, _) = E2E.registerWithId() + val title = "Live $stamp" + val albumId = E2E.createAlbum(ownerToken, title) + E2E.acceptInvite(memberToken, E2E.invite(ownerToken, albumId)) + + // The album reaches the list over the personal stream: no refresh is issued. + awaitTag(TAG_ALBUM_CARD_PREFIX + albumId) + + // Open the album, then rename it from the owner account. + compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + albumId).performClick() + compose.waitUntil(TIMEOUT) { + compose.onAllNodesWithTag(TAG_ALBUM_GRID).fetchSemanticsNodes().isNotEmpty() || + compose.onAllNodesWithText("В альбоме пока нет медиа").fetchSemanticsNodes().isNotEmpty() + } + + val renamed = "$title renamed" + E2E.renameAlbum(ownerToken, albumId, renamed) + awaitText(renamed) + + // Removing the member takes the album out of the list and the open screen + // back to it (the event arrives only over the personal stream). + E2E.removeMember(ownerToken, albumId, memberId) + awaitAlbumsScreen() + awaitTagGone(TAG_ALBUM_CARD_PREFIX + albumId) + compose.onNodeWithText(renamed).assertDoesNotExist() + } + + private companion object { + const val TIMEOUT = 30_000L + } +}