test(android): сквозной сценарий живого обновления на эмуляторе

LiveAlbumsE2ETest против локального сервера и двух аккаунтов: участник
регистрируется на устройстве, владелец создаёт альбом и приглашает его,
альбом появляется в списке без обновления, переименование меняет открытый
экран, исключение убирает альбом и возвращает экран к списку.

- E2E: хелперы invite/accept/rename/remove и получение id пользователя
This commit is contained in:
iu
2026-10-16 12:48:02 +03:00
parent 9061f7fc44
commit 3178699213
2 changed files with 233 additions and 0 deletions
@@ -78,6 +78,86 @@ object E2E {
} }
} }
/** Registers a fresh user and returns the access token alongside the user id. */
fun registerWithId(): Pair<String, String> {
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<com.galo.android.data.network.dto.UserEnvelopeDto>(
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() fun upload(token: String, albumId: String, filename: String, bytes: ByteArray, mime: String) { val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
.addFormDataPart("files", filename, bytes.toRequestBody(mime.toMediaType())) .addFormDataPart("files", filename, bytes.toRequestBody(mime.toMediaType()))
@@ -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<MainActivity>()
@get:Rule
val rules: TestRule = RuleChain.outerRule(mediaPermissionRule).around(compose)
private fun app() = ApplicationProvider.getApplicationContext<GaloApplication>()
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
}
}