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)
|
||||
|
||||
Reference in New Issue
Block a user