package social.grain import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import social.grain.data.model.BlobRef import social.grain.data.upload.GalleryDraft import social.grain.data.upload.GalleryDraftStore import social.grain.data.upload.GalleryUploadCenter import social.grain.data.upload.UploadStage import social.grain.support.Harness import social.grain.support.appContext import social.grain.support.testJson import social.grain.util.nowIso import java.time.Instant /** * Publishing a gallery, and picking one up where a dropped connection left it. * * The whole design of this exists for one failure: a gallery posted twice * because a retry started over. Every record key is assigned when the draft is * built and every uploaded blob is checkpointed, so what these tests actually * pin down is that a retry writes the *same* records and re-uploads only what * hadn't landed. */ @RunWith(AndroidJUnit4::class) class GalleryUploadTest { private lateinit var harness: Harness private lateinit var store: GalleryDraftStore private lateinit var center: GalleryUploadCenter private val repo = "did:plc:me" @Before fun setUp() { harness = Harness() store = GalleryDraftStore(appContext, testJson) center = GalleryUploadCenter(appContext, store) runBlocking { store.loadAll().forEach { store.delete(it.id) } } harness.server .on( "com.atproto.repo.uploadBlob", """{"blob":{"${'$'}type":"blob","ref":{"${'$'}link":"bafyNew"},""" + """"mimeType":"image/jpeg","size":9}}""", ) .on("com.atproto.repo.applyWrites", """{"results":[]}""") } @After fun tearDown() { runBlocking { store.loadAll().forEach { store.delete(it.id) } } harness.tearDown() } private fun photo( id: String, uploaded: Boolean = false, alt: String = "", exif: Boolean = false, ) = GalleryDraft.Photo( id = id, fileName = "$id.jpg", width = 3000, height = 2000, alt = alt, exif = if (exif) buildJsonObject { put("make", "Fujifilm") } else null, photoRkey = "photo-$id", exifRkey = "exif-$id", itemRkey = "item-$id", blob = if (uploaded) { BlobRef("blob", BlobRef.BlobLink("bafyOld"), "image/jpeg", 4) } else { null }, ) private fun draft( id: String = "draft-1", photos: List = listOf(photo("p1")), title: String = "A walk in the fog", description: String = "", labels: List = emptyList(), location: GalleryDraft.Location? = null, includeExif: Boolean = true, committed: Boolean = false, createdAt: String = nowIso(), ) = GalleryDraft( id = id, repo = repo, title = title, description = description, labels = labels, location = location, includeExif = includeExif, createdAt = createdAt, galleryRkey = "gallery-rkey", blueskyRkey = "bsky-rkey", photos = photos, committed = committed, ) private fun stage(draft: GalleryDraft) = runBlocking { store.save(draft) draft.photos.forEach { store.writeImage(ByteArray(9) { 1 }, draft.id, it.fileName) } } private suspend fun auth() = harness.auth.authContext() // MARK: - Publishing @Test fun aDraftUploadsItsBlobsThenCommitsEverythingAtOnce() = runBlocking { val draft = draft(photos = listOf(photo("p1"), photo("p2"))) stage(draft) assertTrue(center.publish(draft, harness.client, ::auth)) assertEquals(2, harness.server.countOf("com.atproto.repo.uploadBlob")) assertEquals("One atomic commit, not a write per record", 1, harness.server.countOf("com.atproto.repo.applyWrites")) assertEquals(UploadStage.Finished, center.stage.value) assertNull("A published draft leaves nothing on disk", store.load(draft.id)) assertNull(center.activeDraft.value) } @Test fun aPhotoWhoseBlobAlreadyLandedIsNotUploadedAgain() = runBlocking { val draft = draft(photos = listOf(photo("p1", uploaded = true), photo("p2"))) stage(draft) center.publish(draft, harness.client, ::auth) assertEquals("Only the photo that hadn't landed", 1, harness.server.countOf("com.atproto.repo.uploadBlob")) } @Test fun theCommitCarriesAPhotoAnItemAndTheGalleryForEachFrame() = runBlocking { val draft = draft(photos = listOf(photo("p1", alt = "A tree"), photo("p2"))) stage(draft) center.publish(draft, harness.client, ::auth) val writes = harness.server.lastCall("com.atproto.repo.applyWrites").json["writes"]!!.jsonArray val collections = writes.map { it.jsonObject["collection"]!!.jsonPrimitive.content } assertEquals(2, collections.count { it == "social.grain.photo" }) assertEquals(1, collections.count { it == "social.grain.gallery" }) assertEquals(2, collections.count { it == "social.grain.gallery.item" }) val firstPhoto = writes.first { it.jsonObject["collection"]!!.jsonPrimitive.content == "social.grain.photo" } assertEquals("photo-p1", firstPhoto.jsonObject["rkey"]!!.jsonPrimitive.content) assertEquals("A tree", firstPhoto.jsonObject["value"]!!.jsonObject["alt"]!!.jsonPrimitive.content) val secondPhoto = writes.last { it.jsonObject["collection"]!!.jsonPrimitive.content == "social.grain.photo" } assertEquals("", secondPhoto.jsonObject["value"]!!.jsonObject["alt"]!!.jsonPrimitive.content) } @Test fun exifIsWrittenOnlyWhenTheDraftAskedForIt() = runBlocking { val withExif = draft(id = "d-exif", photos = listOf(photo("p1", exif = true))) stage(withExif) center.publish(withExif, harness.client, ::auth) assertTrue( harness.server.lastCall("com.atproto.repo.applyWrites").body.contains("social.grain.photo.exif"), ) val without = draft( id = "d-noexif", photos = listOf(photo("p2", exif = true)), includeExif = false, ) stage(without) center.publish(without, harness.client, ::auth) assertFalse( harness.server.lastCall("com.atproto.repo.applyWrites").body.contains("social.grain.photo.exif"), ) } @Test fun aLocationAndSelfLabelsRideAlongOnTheGalleryRecord() = runBlocking { val draft = draft( description = "Six frames", labels = listOf("nudity"), location = GalleryDraft.Location( h3 = "8928308280fffff", name = "Mission District", address = buildJsonObject { put("country", "US") }, ), ) stage(draft) center.publish(draft, harness.client, ::auth) val gallery = harness.server.lastCall("com.atproto.repo.applyWrites").json["writes"]!!.jsonArray .first { it.jsonObject["collection"]!!.jsonPrimitive.content == "social.grain.gallery" } .jsonObject["value"]!!.jsonObject assertEquals("Six frames", gallery["description"]!!.jsonPrimitive.content) assertEquals("8928308280fffff", gallery["location"]!!.jsonObject["value"]!!.jsonPrimitive.content) assertEquals("US", gallery["address"]!!.jsonObject["country"]!!.jsonPrimitive.content) assertEquals( "nudity", gallery["labels"]!!.jsonObject["values"]!!.jsonArray.single() .jsonObject["val"]!!.jsonPrimitive.content, ) } @Test fun aDraftAlreadyCommittedSkipsStraightToCleanUp() = runBlocking { val draft = draft(photos = listOf(photo("p1", uploaded = true)), committed = true) stage(draft) assertTrue(center.publish(draft, harness.client, ::auth)) assertEquals(0, harness.server.countOf("com.atproto.repo.applyWrites")) assertNull(store.load(draft.id)) } // MARK: - Failure and resume @Test fun anUploadThatFailsKeepsTheDraftSoARetryResumes() = runBlocking { harness.server.on("com.atproto.repo.uploadBlob", """{"error":"no"}""", code = 400) val draft = draft(photos = listOf(photo("p1"), photo("p2"))) stage(draft) assertFalse(center.publish(draft, harness.client, ::auth)) assertTrue(center.stage.value is UploadStage.Failed) assertNotNull("The draft is still on disk", store.load(draft.id)) assertNotNull(center.activeDraft.value) harness.server.on( "com.atproto.repo.uploadBlob", """{"blob":{"${'$'}type":"blob","ref":{"${'$'}link":"bafyNew"},"mimeType":"image/jpeg","size":9}}""", ) assertTrue(center.retry(harness.client, ::auth)) assertEquals(UploadStage.Finished, center.stage.value) } @Test fun aRetryOnlyReUploadsTheBlobsThatHadNotLanded() = runBlocking { val draft = draft(photos = listOf(photo("p1"), photo("p2"))) stage(draft) // The first photo goes up, the second is refused. harness.server .on( "com.atproto.repo.uploadBlob", """{"blob":{"${'$'}type":"blob","ref":{"${'$'}link":"bafy1"},"mimeType":"image/jpeg","size":9}}""", ) .then("com.atproto.repo.uploadBlob", """{"error":"no"}""", code = 400) assertFalse(center.publish(draft, harness.client, ::auth)) assertEquals(1, store.load(draft.id)!!.uploadedPhotoCount) val uploadsSoFar = harness.server.countOf("com.atproto.repo.uploadBlob") harness.server.on( "com.atproto.repo.uploadBlob", """{"blob":{"${'$'}type":"blob","ref":{"${'$'}link":"bafy2"},"mimeType":"image/jpeg","size":9}}""", ) assertTrue(center.retry(harness.client, ::auth)) assertEquals( "Exactly one more upload — the one that hadn't landed", uploadsSoFar + 1, harness.server.countOf("com.atproto.repo.uploadBlob"), ) } @Test fun aCommitThatFailsButLandedAnywayIsNotReportedAsAFailure() = runBlocking { harness.server .on("com.atproto.repo.applyWrites", """{"error":"already exists"}""", code = 409) .on("com.atproto.repo.getRecord", """{"uri":"at://$repo/social.grain.gallery/gallery-rkey"}""") val draft = draft(photos = listOf(photo("p1", uploaded = true))) stage(draft) assertTrue( "The records are on the PDS; the response was just lost", center.publish(draft, harness.client, ::auth), ) assertNull(store.load(draft.id)) } @Test fun aCommitThatFailedAndDidNotLandIsReportedAsAFailure() = runBlocking { harness.server .on("com.atproto.repo.applyWrites", """{"error":"nope"}""", code = 400) .on("com.atproto.repo.getRecord", """{"error":"not found"}""", code = 404) val draft = draft(photos = listOf(photo("p1", uploaded = true))) stage(draft) assertFalse(center.publish(draft, harness.client, ::auth)) assertNotNull(store.load(draft.id)) } @Test fun aFailureMessageNamesWhatTheUserCanDoAboutIt() = runBlocking { val draft = draft(photos = listOf(photo("p1", uploaded = true))) stage(draft) harness.server .on("com.atproto.repo.applyWrites", """{"error":"slow down"}""", code = 429) .on("com.atproto.repo.getRecord", """{"error":"not found"}""", code = 404) center.publish(draft, harness.client, ::auth) assertEquals( "Your server is rate limiting uploads. Wait a moment and try again.", (center.stage.value as UploadStage.Failed).message, ) harness.server.on("com.atproto.repo.applyWrites", """{"error":"oops"}""", code = 503) center.publish(draft, harness.client, ::auth) assertEquals( "The server had a problem. Nothing was lost — try again in a moment.", (center.stage.value as UploadStage.Failed).message, ) center.clearFailure() assertEquals(UploadStage.Idle, center.stage.value) } @Test fun publishingWhileSignedOutSaysSoRatherThanFailingQuietly() = runBlocking { val draft = draft(photos = listOf(photo("p1", uploaded = true))) stage(draft) assertFalse(center.publish(draft, harness.client) { null }) assertEquals( "You're signed out. Sign in again to finish posting.", (center.stage.value as UploadStage.Failed).message, ) } @Test fun retryingWithNothingActiveDoesNothing() = runBlocking { assertFalse(center.retry(harness.client, ::auth)) } @Test fun leftoverDraftsAreResumedOneAtATime() = runBlocking { stage(draft(id = "d1", photos = listOf(photo("a", uploaded = true)))) stage(draft(id = "d2", photos = listOf(photo("b", uploaded = true)))) center.accountChanged(repo) assertEquals(2, center.pending.value.size) var published = false center.onPublished = { published = true } center.resumePending(harness.client, ::auth) assertTrue(published) assertTrue(center.pending.value.isEmpty()) assertEquals(2, harness.server.countOf("com.atproto.repo.applyWrites")) } @Test fun aDraftBelongingToAnotherAccountIsNotOffered() = runBlocking { stage(draft(id = "d-other").copy(repo = "did:plc:somebody-else").also { store.save(it) }) center.accountChanged(repo) assertTrue(center.pending.value.isEmpty()) } @Test fun switchingAccountsTakesTheActiveDraftOffScreen() = runBlocking { harness.server.on("com.atproto.repo.uploadBlob", """{"error":"no"}""", code = 400) val draft = draft(photos = listOf(photo("p1"))) stage(draft) center.publish(draft, harness.client, ::auth) assertNotNull(center.activeDraft.value) center.accountChanged("did:plc:somebody-else") assertNull(center.activeDraft.value) assertEquals(UploadStage.Idle, center.stage.value) } @Test fun aDraftCanBeDiscardedOutright() = runBlocking { stage(draft(id = "d-discard", photos = listOf(photo("p1", uploaded = true)))) center.accountChanged(repo) assertEquals(1, center.pending.value.size) center.discard("d-discard") assertTrue(center.pending.value.isEmpty()) assertNull(store.load("d-discard")) } // MARK: - The store underneath @Test fun aDraftUnfinishedForOverAWeekIsWrittenOff() = runBlocking { val old = draft(id = "d-old", createdAt = nowIso(Instant.now().minusSeconds(60 * 60 * 24 * 8))) stage(old) assertTrue(store.loadAll().none { it.id == "d-old" }) assertNull(store.load("d-old")) } @Test fun aDirectoryWithNoReadableDraftIsSweptUp() = runBlocking { store.directory("d-junk").mkdirs() java.io.File(store.directory("d-junk"), "draft.json").writeText("not json") store.loadAll() assertFalse(store.directory("d-junk").exists()) } @Test fun theResumeBannerHasSomethingToCallAnUntitledGallery() { assertEquals("Untitled gallery", draft(title = " ").displayTitle) assertEquals("A walk in the fog", draft().displayTitle) } @Test fun theProgressBarReadsAsOneRunThroughPreparingAndUploading() { assertEquals(0.25f, UploadStage.Preparing(1, 2).fraction) assertEquals(0.75f, UploadStage.Uploading(1, 2).fraction) assertEquals(1f, UploadStage.Publishing.fraction) assertNull(UploadStage.Idle.fraction) assertNull(UploadStage.Preparing(0, 0).fraction) assertTrue(UploadStage.Uploading(0, 1).isBusy) assertFalse(UploadStage.Finished.isBusy) assertEquals("Preparing photo 1 of 2", UploadStage.Preparing(0, 2).label) assertEquals("Uploading photo 2 of 2", UploadStage.Uploading(1, 2).label) assertEquals("Publishing gallery", UploadStage.Publishing.label) assertEquals("Posted", UploadStage.Finished.label) assertEquals("boom", UploadStage.Failed("boom").label) assertEquals("", UploadStage.Idle.label) } }