test(android): покрыть потерю и возврат серверной копии

Unit-тесты планировщика, координатора, политики и восстановления, Compose-
тесты маркера и шита, instrumented-проверка удаления превью с устройства и
E2E-сценарии потери/возврата серверной копии. E2E не прогонялись: нужен
эмулятор и сервер на 10.0.2.2:8088.
This commit is contained in:
iu
2026-10-16 14:12:58 +03:00
parent 3ff8f80eed
commit 5cfa045171
12 changed files with 359 additions and 0 deletions
@@ -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( private suspend fun waitForLink(
container: com.galo.android.di.AppContainer, container: com.galo.android.di.AppContainer,
bucket: String, 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 { fun awaitPhotoWithPlayback(token: String, albumId: String, timeoutMs: Long): PhotoDto {
val deadline = System.currentTimeMillis() + timeoutMs val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) { 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.DeviceAssetEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.data.db.entity.SyncedAlbumEntity
import com.galo.android.data.media.MediaStoreDeviceSource 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.OffloadManager
import com.galo.android.data.sync.OffloadResult import com.galo.android.data.sync.OffloadResult
import com.galo.android.data.sync.OriginalFetcher import com.galo.android.data.sync.OriginalFetcher
@@ -198,6 +199,22 @@ class OffloadInstrumentedTest {
assertEquals("srvX", db.syncDao().getSyncedAlbum(bucket)?.serverAlbumId) 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 @Test
fun restoreWritesTheOriginalBackOverThePreview() = runBlocking { fun restoreWritesTheOriginalBackOverThePreview() = runBlocking {
val (uri, bucket) = insertImage(2400, 1800) val (uri, bucket) = insertImage(2400, 1800)
@@ -50,4 +50,13 @@ class RestoreManagerTest {
assertEquals(listOf("b1/1"), targets.map { it.assetKey }) 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) 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 @Test
fun `move delegates to the server`() = runBlocking { fun `move delegates to the server`() = runBlocking {
coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2") coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2")
@@ -104,6 +104,48 @@ class SyncPlannerTest {
assertTrue("an offloaded asset must not be uploaded again", actions.isEmpty()) 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 @Test
fun `edited content is re-uploaded and the old upload removed`() { fun `edited content is re-uploaded and the old upload removed`() {
val asset = local("b1/1", name = "a.jpg", hash = "h2") val asset = local("b1/1", name = "a.jpg", hash = "h2")
@@ -46,4 +46,12 @@ class PhotoAttributeResolverTest {
assertTrue(source is PhotoAttributeSource.Local) 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)) 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 @Test
fun `uploaded and awaiting-free are offloaded only in move mode`() { fun `uploaded and awaiting-free are offloaded only in move mode`() {
for (state in listOf(LocalAssetState.UPLOADED, LocalAssetState.AWAITING_FREE)) { for (state in listOf(LocalAssetState.UPLOADED, LocalAssetState.AWAITING_FREE)) {
@@ -323,6 +323,23 @@ class DeviceAlbumContentTest {
compose.onNodeWithTag(TAG_TRANSFERRED_PREFIX + 1, useUnmergedTree = true).assertDoesNotExist() 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 @Test
fun `settings panel offers the three sync modes`() { fun `settings panel offers the three sync modes`() {
compose.setContent { settings(DeviceAlbumViewModel.UiState(name = "Camera")) } compose.setContent { settings(DeviceAlbumViewModel.UiState(name = "Camera")) }
@@ -46,4 +46,22 @@ class DeviceAlbumOriginalsTest {
val map = serverOriginalsByMediaId(listOf(asset(1, LocalAssetState.PREVIEW, null))) val map = serverOriginalsByMediaId(listOf(asset(1, LocalAssetState.PREVIEW, null)))
assertTrue(map.isEmpty()) 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_DOWNLOAD).assertDoesNotExist()
compose.onNodeWithTag(TAG_SHEET_DELETE).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)
}
} }