test(android): покрыть потерю и возврат серверной копии
Unit-тесты планировщика, координатора, политики и восстановления, Compose- тесты маркера и шита, instrumented-проверка удаления превью с устройства и E2E-сценарии потери/возврата серверной копии. E2E не прогонялись: нужен эмулятор и сервер на 10.0.2.2:8088.
This commit is contained in:
@@ -293,6 +293,95 @@ class DeviceSyncE2ETest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletedServerCopyMarksThePreviewAsOrphaned() = runBlocking {
|
||||
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
|
||||
val container = app.container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val serverAlbum = E2E.createAlbum(token, "Orphan ${System.currentTimeMillis()}")
|
||||
|
||||
val (uri, bucket) = insertImage()
|
||||
val mediaId = ContentUris.parseId(uri)
|
||||
val key = DeviceAssetEntity.key(bucket, mediaId)
|
||||
container.syncRepository.link(bucket, "O", serverAlbum, SyncMode.MOVE)
|
||||
assertTrue(container.syncCoordinator.reconcile(bucket))
|
||||
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
|
||||
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
|
||||
|
||||
// Somebody removes the photo from the server album.
|
||||
val photo = E2E.listPhotos(token, serverAlbum).first()
|
||||
E2E.deletePhoto(token, photo.id)
|
||||
|
||||
container.syncCheck.checkGallery(bucket, recoverIndex = true)
|
||||
|
||||
assertTrue(
|
||||
"the offloaded preview should be marked as orphaned",
|
||||
waitForState(container, bucket, key, LocalAssetState.ORPHANED),
|
||||
)
|
||||
|
||||
// The grid derives the crossed-out cloud from the view model set.
|
||||
val viewModel = DeviceAlbumViewModel(container, bucket)
|
||||
val collector = launch { viewModel.state.collect { } }
|
||||
try {
|
||||
var orphaned = false
|
||||
val deadline = System.currentTimeMillis() + 8_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
orphaned = mediaId in viewModel.state.value.orphanedMediaIds
|
||||
if (orphaned) break
|
||||
delay(200)
|
||||
}
|
||||
assertTrue("the lost preview should be flagged for the grid", orphaned)
|
||||
} finally {
|
||||
collector.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returnedOriginalIsUploadedAgainAndOffloaded() = runBlocking {
|
||||
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
|
||||
val container = app.container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val serverAlbum = E2E.createAlbum(token, "Return ${System.currentTimeMillis()}")
|
||||
|
||||
val (uri, bucket) = insertImage()
|
||||
val mediaId = ContentUris.parseId(uri)
|
||||
val key = DeviceAssetEntity.key(bucket, mediaId)
|
||||
val originalBytes = resolver.openInputStream(uri)!!.use { it.readBytes() }
|
||||
|
||||
container.syncRepository.link(bucket, "R", serverAlbum, SyncMode.MOVE)
|
||||
assertTrue(container.syncCoordinator.reconcile(bucket))
|
||||
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
|
||||
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
|
||||
|
||||
val photo = E2E.listPhotos(token, serverAlbum).first()
|
||||
E2E.deletePhoto(token, photo.id)
|
||||
container.syncCheck.checkGallery(bucket, recoverIndex = true)
|
||||
assertTrue(waitForState(container, bucket, key, LocalAssetState.ORPHANED))
|
||||
|
||||
// The original comes back to the device: upload it again, then offload.
|
||||
resolver.openOutputStream(uri)!!.use { it.write(originalBytes) }
|
||||
|
||||
var offloadedAgain = false
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
container.syncCheck.checkGallery(bucket, recoverIndex = true)
|
||||
delay(1_000)
|
||||
val state = container.syncRepository.assets(bucket)
|
||||
.firstOrNull { it.assetKey == key }?.state
|
||||
if (state == LocalAssetState.PREVIEW.name) {
|
||||
offloadedAgain = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assertTrue("the returned original should be uploaded and offloaded again", offloadedAgain)
|
||||
assertTrue(
|
||||
"the server album should hold a photo again",
|
||||
E2E.listPhotos(token, serverAlbum).isNotEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun waitForLink(
|
||||
container: com.galo.android.di.AppContainer,
|
||||
bucket: String,
|
||||
|
||||
@@ -184,6 +184,18 @@ object E2E {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a photo on the server (drives a live `photo.deleted`). */
|
||||
fun deletePhoto(token: String, photoId: String) {
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/photos/$photoId")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.delete()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
check(response.isSuccessful) { "delete photo failed: HTTP ${response.code}" }
|
||||
}
|
||||
}
|
||||
|
||||
fun awaitPhotoWithPlayback(token: String, albumId: String, timeoutMs: Long): PhotoDto {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.galo.android.data.db.GaloDatabase
|
||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
||||
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
||||
import com.galo.android.data.media.MediaStoreDeviceSource
|
||||
import com.galo.android.data.repo.SyncRepository
|
||||
import com.galo.android.data.sync.OffloadManager
|
||||
import com.galo.android.data.sync.OffloadResult
|
||||
import com.galo.android.data.sync.OriginalFetcher
|
||||
@@ -198,6 +199,22 @@ class OffloadInstrumentedTest {
|
||||
assertEquals("srvX", db.syncDao().getSyncedAlbum(bucket)?.serverAlbumId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingALostPreviewDeletesTheFileAndTheIndexRow() = runBlocking {
|
||||
val (uri, bucket) = insertImage(800, 600)
|
||||
val asset = assetOf(uri, bucket, "lost.jpg", "image/jpeg")
|
||||
.copy(state = LocalAssetState.ORPHANED.name)
|
||||
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
|
||||
db.syncDao().upsertAssets(listOf(asset))
|
||||
val repository = SyncRepository(db.syncDao(), MediaStoreDeviceSource(context))
|
||||
|
||||
val removed = repository.removeDeviceAsset(asset.assetKey, uri)
|
||||
|
||||
assertTrue("the preview file should be gone", removed)
|
||||
assertTrue("the media row should be gone", !exists(uri))
|
||||
assertTrue("the index row should be gone", db.syncDao().getAsset(asset.assetKey) == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreWritesTheOriginalBackOverThePreview() = runBlocking {
|
||||
val (uri, bucket) = insertImage(2400, 1800)
|
||||
|
||||
@@ -50,4 +50,13 @@ class RestoreManagerTest {
|
||||
|
||||
assertEquals(listOf("b1/1"), targets.map { it.assetKey })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orphaned previews are not restored`() {
|
||||
val orphaned = asset("b1/1", state = LocalAssetState.ORPHANED)
|
||||
|
||||
val targets = RestoreManager.restoreTargets(listOf(orphaned))
|
||||
|
||||
assertTrue("a lost server copy has nothing to restore", targets.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +310,97 @@ class SyncCoordinatorTest {
|
||||
assertEquals(listOf("b1/1"), offloader.offloaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a preview whose server copy is gone becomes orphaned`() = runBlocking {
|
||||
link(SyncMode.MOVE)
|
||||
db.syncDao().upsertAssets(
|
||||
listOf(
|
||||
DeviceAssetEntity(
|
||||
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
|
||||
"orig", "p1", 1L, LocalAssetState.PREVIEW.name,
|
||||
),
|
||||
),
|
||||
)
|
||||
provider.items = listOf(asset("b1/1", hash = "preview"))
|
||||
|
||||
coordinator.reconcile("b1")
|
||||
|
||||
assertEquals(LocalAssetState.ORPHANED.name, db.syncDao().getAsset("b1/1")?.state)
|
||||
assertTrue("an orphaned preview must not be uploaded", queue.enqueued.isEmpty())
|
||||
assertTrue("the server copy is already gone", remote.deletes.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an orphaned preview whose photo returns becomes a preview again`() = runBlocking {
|
||||
link(SyncMode.MOVE)
|
||||
remote.serverMap = mapOf("orig" to "p1")
|
||||
db.syncDao().upsertAssets(
|
||||
listOf(
|
||||
DeviceAssetEntity(
|
||||
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
|
||||
"orig", "p1", 1L, LocalAssetState.ORPHANED.name,
|
||||
),
|
||||
),
|
||||
)
|
||||
provider.items = listOf(asset("b1/1", hash = "preview"))
|
||||
|
||||
coordinator.reconcile("b1")
|
||||
|
||||
assertEquals(LocalAssetState.PREVIEW.name, db.syncDao().getAsset("b1/1")?.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a returned original is uploaded again`() = runBlocking {
|
||||
link(SyncMode.MOVE)
|
||||
db.syncDao().upsertAssets(
|
||||
listOf(
|
||||
DeviceAssetEntity(
|
||||
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
|
||||
"orig", "p1", 1L, LocalAssetState.ORPHANED.name,
|
||||
),
|
||||
),
|
||||
)
|
||||
provider.items = listOf(asset("b1/1", hash = "orig"))
|
||||
|
||||
coordinator.reconcile("b1")
|
||||
|
||||
assertEquals(1, queue.enqueued.size)
|
||||
assertEquals("b1/1", queue.enqueued.single().third.assetKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `after a returned original is uploaded it is offloaded again`() = runBlocking {
|
||||
link(SyncMode.MOVE)
|
||||
db.syncDao().upsertAssets(
|
||||
listOf(
|
||||
DeviceAssetEntity(
|
||||
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
|
||||
"orig", "p1", 1L, LocalAssetState.ORPHANED.name,
|
||||
),
|
||||
),
|
||||
)
|
||||
provider.items = listOf(asset("b1/1", hash = "orig"))
|
||||
|
||||
coordinator.reconcile("b1")
|
||||
assertEquals(1, queue.enqueued.size)
|
||||
|
||||
// The upload finished: the row points at a live photo again.
|
||||
db.syncDao().upsertAssets(
|
||||
listOf(
|
||||
DeviceAssetEntity(
|
||||
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
|
||||
"orig", "p2", 1L, LocalAssetState.AWAITING_FREE.name,
|
||||
),
|
||||
),
|
||||
)
|
||||
remote.serverMap = mapOf("orig" to "p2")
|
||||
offloader.result = OffloadResult.Done
|
||||
|
||||
coordinator.reconcile("b1")
|
||||
|
||||
assertEquals(listOf("b1/1"), offloader.offloaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `move delegates to the server`() = runBlocking {
|
||||
coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2")
|
||||
|
||||
@@ -104,6 +104,48 @@ class SyncPlannerTest {
|
||||
assertTrue("an offloaded asset must not be uploaded again", actions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an orphaned preview is not re-uploaded`() {
|
||||
val asset = local("b1/1", hash = "preview-hash")
|
||||
val actions = SyncPlanner.plan(
|
||||
local = listOf(asset),
|
||||
index = listOf(
|
||||
IndexedAsset("b1/1", "p1", "original-hash", "a.jpg", LocalAssetState.ORPHANED.name),
|
||||
),
|
||||
serverChecksums = emptyMap(),
|
||||
hashesPresentElsewhere = emptySet(),
|
||||
)
|
||||
assertTrue("an orphaned preview must not be uploaded as an original", actions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a returned original is uploaded again`() {
|
||||
val asset = local("b1/1", hash = "original-hash")
|
||||
val actions = SyncPlanner.plan(
|
||||
local = listOf(asset),
|
||||
index = listOf(
|
||||
IndexedAsset("b1/1", "p1", "original-hash", "a.jpg", LocalAssetState.ORPHANED.name),
|
||||
),
|
||||
serverChecksums = emptyMap(),
|
||||
hashesPresentElsewhere = emptySet(),
|
||||
)
|
||||
assertEquals(listOf(SyncAction.Upload(asset)), actions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a returned original already on the server is linked`() {
|
||||
val asset = local("b1/1", hash = "original-hash")
|
||||
val actions = SyncPlanner.plan(
|
||||
local = listOf(asset),
|
||||
index = listOf(
|
||||
IndexedAsset("b1/1", "p1", "original-hash", "a.jpg", LocalAssetState.ORPHANED.name),
|
||||
),
|
||||
serverChecksums = mapOf("original-hash" to "p9"),
|
||||
hashesPresentElsewhere = emptySet(),
|
||||
)
|
||||
assertEquals(listOf(SyncAction.Link(asset, "p9")), actions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edited content is re-uploaded and the old upload removed`() {
|
||||
val asset = local("b1/1", name = "a.jpg", hash = "h2")
|
||||
|
||||
+8
@@ -46,4 +46,12 @@ class PhotoAttributeResolverTest {
|
||||
|
||||
assertTrue(source is PhotoAttributeSource.Local)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orphaned media resolves to the orphaned source even with a stale mapping`() {
|
||||
val source = PhotoAttributeResolver.resolve(media(4L), serverPhotoId = "p42", orphaned = true)
|
||||
|
||||
assertTrue(source is PhotoAttributeSource.Orphaned)
|
||||
assertEquals(4L, (source as PhotoAttributeSource.Orphaned).media.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.galo.android.domain.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class LocalAssetStateTest {
|
||||
|
||||
@Test
|
||||
fun `every state round-trips through its stored name`() {
|
||||
for (state in LocalAssetState.entries) {
|
||||
assertEquals(state, LocalAssetState.from(state.name))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orphaned round-trips`() {
|
||||
assertEquals(LocalAssetState.ORPHANED, LocalAssetState.from(LocalAssetState.ORPHANED.name))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `from returns local for unknown values`() {
|
||||
assertEquals(LocalAssetState.LOCAL, LocalAssetState.from("NOPE"))
|
||||
assertEquals(LocalAssetState.LOCAL, LocalAssetState.from(null))
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,15 @@ class GallerySyncPolicyTest {
|
||||
assertEquals(LocalFix.NONE, decide(SyncMode.MOVE, LocalAssetState.PREVIEW, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orphaned is never fixed locally in any mode`() {
|
||||
for (mode in SyncMode.entries) {
|
||||
for (linked in listOf(true, false)) {
|
||||
assertEquals(LocalFix.NONE, decide(mode, LocalAssetState.ORPHANED, linked))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uploaded and awaiting-free are offloaded only in move mode`() {
|
||||
for (state in listOf(LocalAssetState.UPLOADED, LocalAssetState.AWAITING_FREE)) {
|
||||
|
||||
@@ -323,6 +323,23 @@ class DeviceAlbumContentTest {
|
||||
compose.onNodeWithTag(TAG_TRANSFERRED_PREFIX + 1, useUnmergedTree = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orphaned media shows a crossed-out cloud badge`() {
|
||||
compose.setContent {
|
||||
content(
|
||||
DeviceAlbumViewModel.UiState(
|
||||
name = "Camera",
|
||||
items = listOf(media(1), media(2)),
|
||||
orphanedMediaIds = setOf(1L),
|
||||
),
|
||||
)
|
||||
}
|
||||
compose.onNodeWithTag(TAG_ORPHANED_PREFIX + 1, useUnmergedTree = true).assertExists()
|
||||
compose.onNodeWithTag(TAG_ORPHANED_PREFIX + 2, useUnmergedTree = true).assertDoesNotExist()
|
||||
// A lost copy is not shown with the plain cloud.
|
||||
compose.onNodeWithTag(TAG_TRANSFERRED_PREFIX + 1, useUnmergedTree = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settings panel offers the three sync modes`() {
|
||||
compose.setContent { settings(DeviceAlbumViewModel.UiState(name = "Camera")) }
|
||||
|
||||
@@ -46,4 +46,22 @@ class DeviceAlbumOriginalsTest {
|
||||
val map = serverOriginalsByMediaId(listOf(asset(1, LocalAssetState.PREVIEW, null)))
|
||||
assertTrue(map.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orphaned preview never uses the server original`() {
|
||||
val map = serverOriginalsByMediaId(listOf(asset(1, LocalAssetState.ORPHANED, "p1")))
|
||||
assertTrue("a lost server copy has no original to fetch", map.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orphaned media ids are collected for the grid marker`() {
|
||||
val ids = orphanedMediaIds(
|
||||
listOf(
|
||||
asset(1, LocalAssetState.ORPHANED, "p1"),
|
||||
asset(2, LocalAssetState.PREVIEW, "p2"),
|
||||
asset(3, LocalAssetState.ORPHANED, "p3"),
|
||||
),
|
||||
)
|
||||
assertEquals(setOf(1L, 3L), ids)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,4 +157,26 @@ class PhotoAttributesSheetTest {
|
||||
compose.onNodeWithTag(TAG_SHEET_DOWNLOAD).assertDoesNotExist()
|
||||
compose.onNodeWithTag(TAG_SHEET_DELETE).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orphaned media offers only deleting from the device`() {
|
||||
var deleted = false
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
PhotoAttributesSheet(
|
||||
model = PhotoSheetModel(title = "IMG.jpg", isServer = false),
|
||||
onDelete = { deleted = true },
|
||||
deleteLabel = "Удалить с устройства",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Удалить с устройства").performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_SHEET_DELETE).performScrollTo().performClick()
|
||||
compose.onNodeWithTag(TAG_SHEET_DOWNLOAD).assertDoesNotExist()
|
||||
compose.onNodeWithTag(TAG_SHEET_MOVE).assertDoesNotExist()
|
||||
compose.onNodeWithTag(TAG_SHEET_COVER).assertDoesNotExist()
|
||||
|
||||
assertTrue(deleted)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user