test(android): сквозной сценарий живого обновления на эмуляторе
LiveAlbumsE2ETest против локального сервера и двух аккаунтов: участник регистрируется на устройстве, владелец создаёт альбом и приглашает его, альбом появляется в списке без обновления, переименование меняет открытый экран, исключение убирает альбом и возвращает экран к списку. - E2E: хелперы invite/accept/rename/remove и получение id пользователя
This commit is contained in:
@@ -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()
|
||||
.setType(MultipartBody.FORM)
|
||||
.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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user