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(
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)