~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
447 lines · 16.6 KB Raw History
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<GalleryDraft.Photo> = listOf(photo("p1")),
        title: String = "A walk in the fog",
        description: String = "",
        labels: List<String> = 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)
    }
}