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

grain.social/grain-android

1 collaborator
0
1
main
1005 lines · 41.3 KB Raw History
package social.grain

import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject
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.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import androidx.test.ext.junit.runners.AndroidJUnit4
import social.grain.data.api.ApplyWrite
import social.grain.data.api.BlockService
import social.grain.data.api.CommentService
import social.grain.data.api.FavoriteService
import social.grain.data.api.FollowService
import social.grain.data.api.GalleryService
import social.grain.data.api.PinnedFeed
import social.grain.data.api.ReportSubject
import social.grain.data.api.StoryService
import social.grain.data.api.applyWrites
import social.grain.data.api.createReport
import social.grain.data.api.createRecord
import social.grain.data.api.deleteAccount
import social.grain.data.api.deleteGallery
import social.grain.data.api.deleteRecord
import social.grain.data.api.getActorFavorites
import social.grain.data.api.getActorProfile
import social.grain.data.api.getBlocks
import social.grain.data.api.getCameras
import social.grain.data.api.getCommentThread
import social.grain.data.api.getFeed
import social.grain.data.api.getFollowers
import social.grain.data.api.getFollowing
import social.grain.data.api.getGallery
import social.grain.data.api.getGalleryFavorites
import social.grain.data.api.getKnownFollowers
import social.grain.data.api.getLocations
import social.grain.data.api.getMutes
import social.grain.data.api.getNotifications
import social.grain.data.api.getPreferences
import social.grain.data.api.listAllRecords
import social.grain.data.api.getUnreadCount
import social.grain.data.api.feedUri
import social.grain.data.api.getRecord
import social.grain.data.api.getStories
import social.grain.data.api.getStory
import social.grain.data.api.getStoryArchive
import social.grain.data.api.getStoryAuthors
import social.grain.data.api.markNotificationsSeen
import social.grain.data.api.muteActor
import social.grain.data.api.putPreference
import social.grain.data.api.putRecord
import social.grain.data.api.registerPushToken
import social.grain.data.api.searchActorsTypeahead
import social.grain.data.api.searchGalleries
import social.grain.data.api.searchProfiles
import social.grain.data.api.unmuteActor
import social.grain.data.api.unregisterPushToken
import social.grain.data.model.BlobRef
import social.grain.support.Fixtures
import social.grain.support.StubServer
import social.grain.support.json
import social.grain.support.testAuth
import social.grain.support.assertFails
import social.grain.data.api.XrpcException
import social.grain.support.xrpc

/**
 * Every call the app can make to the appview, exercised against a stub.
 *
 * These functions are one-liners each, which is exactly why they are worth
 * pinning: a query parameter named wrong or a body key spelled differently
 * compiles fine and fails only as an empty screen against the real server. The
 * assertions are on the wire format — NSID, method, parameters, body — because
 * that is the whole content of the layer.
 */
@RunWith(AndroidJUnit4::class)
class EndpointsTest {

    private val server = StubServer()
    private val client = server.xrpc()
    private val auth = testAuth()

    private fun body(nsid: String): JsonObject = server.lastCall(nsid).json

    /** Where the stub server's feeds are: its did:web's feed generators. */
    private val FEEDS = "at://did:web:stub.grain.test/social.grain.feed.generator"

    // MARK: - Feed

    @Test
    fun getFeedSendsEveryOptionalFilterItWasGiven() = runTest {
        server.on(
            "social.grain.feed.getFeed",
            """{"items":[${json(Fixtures.gallery())}],"cursor":"page2"}""",
        )
        val response = client.getFeed(
            feed = "camera",
            limit = 15,
            cursor = "page1",
            actor = "did:plc:alice",
            camera = "Fujifilm X100V",
            location = "8928308280fffff",
            name = "Mission District, San Francisco, US",
            tag = "fog",
        )

        assertEquals(1, response.items?.size)
        assertEquals("page2", response.cursor)

        val call = server.lastCall("social.grain.feed.getFeed")
        assertEquals("GET", call.method)
        assertEquals(
            mapOf(
                "feed" to "$FEEDS/camera",
                "limit" to "15",
                "cursor" to "page1",
                "actor" to "did:plc:alice",
                "camera" to "Fujifilm X100V",
                "location" to "8928308280fffff",
                "name" to "Mission District, San Francisco, US",
                "tag" to "fog",
            ),
            call.params,
        )
    }

    @Test
    fun getFeedOmitsFiltersThatWereNotSet() = runTest {
        server.on("social.grain.feed.getFeed", """{"items":[]}""")
        client.getFeed(feed = "recent")

        assertEquals(
            mapOf("feed" to "$FEEDS/recent", "limit" to "30"),
            server.lastCall("social.grain.feed.getFeed").params,
        )
    }

    @Test
    fun getGalleryAsksForOneGalleryByUri() = runTest {
        val gallery = Fixtures.gallery()
        server.on("social.grain.gallery.getGallery", """{"gallery":${json(gallery)}}""")

        val response = client.getGallery(gallery.uri)

        assertEquals(gallery.uri, response.gallery.uri)
        assertEquals(
            mapOf("gallery" to gallery.uri),
            server.lastCall("social.grain.gallery.getGallery").params,
        )
    }

    @Test
    fun getCommentThreadPagesBySubject() = runTest {
        server.on(
            "social.grain.comment.getThread",
            """{"comments":[${json(Fixtures.comment())}],"totalCount":9}""",
        )
        val response = client.getCommentThread("at://x/gallery/1", limit = 5, cursor = "c")

        assertEquals(9, response.totalCount)
        assertEquals(
            mapOf("subject" to "at://x/gallery/1", "limit" to "5", "cursor" to "c"),
            server.lastCall("social.grain.comment.getThread").params,
        )
    }

    @Test
    fun getActorFavoritesReadsBackAFeedPage() = runTest {
        server.on("social.grain.actor.getFavorites", """{"items":[],"cursor":null}""")
        client.getActorFavorites("did:plc:alice", limit = 7)

        assertEquals(
            mapOf("actor" to "did:plc:alice", "limit" to "7"),
            server.lastCall("social.grain.actor.getFavorites").params,
        )
    }

    @Test
    fun aFeedIsNamedByItsUriUnderTheServersDid() {
        assertEquals("$FEEDS/foryou", client.feedUri("foryou"))
    }

    @Test
    fun getPreferencesDecodesPinnedFeeds() = runTest {
        server.on("social.grain.actor.getPreferences", PREFERENCES)
        val preferences = client.getPreferences().preferences

        assertEquals(true, preferences.includeExif)
        val pin = preferences.pinnedFeeds!!.single()
        assertEquals("camera", pin.feedName)
        assertEquals("Leica M6", pin.feedValue)
    }

    @Test
    fun searchActorsTypeaheadRunsUnauthenticated() = runTest {
        server.on(
            "social.grain.actor.searchActorsTypeahead",
            """{"actors":[{"did":"did:plc:alice","handle":"alice.grain.social"}]}""",
        )
        val response = client.searchActorsTypeahead("ali", limit = 3)

        assertEquals("alice.grain.social", response.actors.single().handle)
        val call = server.lastCall("social.grain.actor.searchActorsTypeahead")
        assertEquals(mapOf("q" to "ali", "limit" to "3"), call.params)
        assertNull("Typeahead must work before there is a session", call.authorization)
    }

    @Test
    fun settingAPhotoPreferenceSendsTheWholeSetBackWithThatOneChanged() = runTest {
        server.on("social.grain.actor.getPreferences", PREFERENCES)
        client.putPreference("includeLocation", value = false, auth = auth)

        val sent = body("social.grain.actor.putPreferences")["preferences"]!!.jsonArray
            .map { it.jsonObject }
        assertEquals(
            "The unknown kind is left out; the others go back",
            setOf(
                "social.grain.actor.defs#pinnedFeedsPref",
                "social.grain.actor.defs#photoMetadataPref",
                "social.grain.actor.defs#themePref",
            ),
            sent.map { it["\$type"]!!.jsonPrimitive.content }.toSet(),
        )
        val metadata = sent.single {
            it["\$type"]!!.jsonPrimitive.content == "social.grain.actor.defs#photoMetadataPref"
        }
        assertEquals("true", metadata["includeExif"]!!.jsonPrimitive.content)
        assertEquals("false", metadata["includeLocation"]!!.jsonPrimitive.content)
        val theme = sent.single { it["\$type"]!!.jsonPrimitive.content == "social.grain.actor.defs#themePref" }
        assertEquals("dark", theme["theme"]!!.jsonPrimitive.content)
    }

    @Test
    fun aFirstPhotoPreferenceIsAddedToAnEmptySet() = runTest {
        server.on("social.grain.actor.getPreferences", """{"preferences":[]}""")
        client.putPreference("includeExif", value = false, auth = auth)

        val sent = body("social.grain.actor.putPreferences")["preferences"]!!.jsonArray.single().jsonObject
        assertEquals("social.grain.actor.defs#photoMetadataPref", sent["\$type"]!!.jsonPrimitive.content)
        assertEquals("false", sent["includeExif"]!!.jsonPrimitive.content)
    }

    // MARK: - Notifications

    @Test
    fun theUnreadCountIsItsOwnMethod() = runTest {
        server.on("social.grain.notification.getUnreadCount", """{"count":4}""")
        assertEquals(4, client.getUnreadCount(auth).count)
        assertTrue(server.lastCall("social.grain.notification.getUnreadCount").params.isEmpty())
    }

    @Test
    fun getNotificationsPagesByCursor() = runTest {
        server.on(
            "social.grain.notification.listNotifications",
            """{"notifications":[],"seenAt":"2026-01-01T00:00:00.000Z"}""",
        )
        val response = client.getNotifications(limit = 100, cursor = "c", auth = auth)
        assertEquals("2026-01-01T00:00:00.000Z", response.seenAt)
        assertEquals(
            mapOf("limit" to "100", "cursor" to "c"),
            server.lastCall("social.grain.notification.listNotifications").params,
        )
    }

    @Test
    fun markingNotificationsSeenSendsTheTime() = runTest {
        client.markNotificationsSeen(auth)

        val sent = body("social.grain.notification.updateSeen")
        assertTrue(
            "seenAt should be an ISO timestamp",
            sent["seenAt"]!!.jsonPrimitive.content.endsWith("Z"),
        )
    }

    // MARK: - Stories

    @Test
    fun storyEndpointsAddressStoriesByActorAndUri() = runTest {
        server.on(
            "social.grain.story.getStories",
            """{"stories":[${json(Fixtures.story())}]}""",
        )
        server.on("social.grain.story.getStory", """{"story":${json(Fixtures.story())}}""")
        server.on("social.grain.story.getArchive", """{"stories":[],"cursor":"c2"}""")
        server.on(
            "social.grain.story.getAuthors",
            """{"authors":[${json(Fixtures.storyAuthor())}]}""",
        )

        assertEquals(1, client.getStories("did:plc:alice").stories.size)
        assertEquals(
            mapOf("actor" to "did:plc:alice"),
            server.lastCall("social.grain.story.getStories").params,
        )

        assertEquals("story1", client.getStory("at://x/story/1").story!!.rkey)
        assertEquals(
            mapOf("story" to "at://x/story/1"),
            server.lastCall("social.grain.story.getStory").params,
        )

        assertEquals("c2", client.getStoryArchive("did:plc:alice", limit = 4, cursor = "c1").cursor)
        assertEquals(
            mapOf("actor" to "did:plc:alice", "limit" to "4", "cursor" to "c1"),
            server.lastCall("social.grain.story.getArchive").params,
        )

        assertEquals(1, client.getStoryAuthors().authors.size)
        assertTrue(server.lastCall("social.grain.story.getAuthors").params.isEmpty())
    }

    // MARK: - Search

    @Test
    fun searchGalleriesCarriesTheFuzzyFlag() = runTest {
        server.on("social.grain.gallery.searchGalleries", """{"items":[],"cursor":null}""")
        client.searchGalleries("fog", limit = 12, cursor = "c", fuzzy = false)

        assertEquals(
            mapOf("q" to "fog", "limit" to "12", "fuzzy" to "false", "cursor" to "c"),
            server.lastCall("social.grain.gallery.searchGalleries").params,
        )
    }

    @Test
    fun searchProfilesDecodesActorListItems() = runTest {
        server.on(
            "social.grain.actor.searchActors",
            """{"items":[{"did":"did:plc:bob","handle":"bob.grain.social"}]}""",
        )
        val response = client.searchProfiles("bob")

        assertEquals("did:plc:bob", response.items!!.single().did)
        assertEquals(
            mapOf("q" to "bob", "limit" to "30"),
            server.lastCall("social.grain.actor.searchActors").params,
        )
    }

    // MARK: - Discovery

    @Test
    fun getCamerasDecodesTheIndexWithItsThumbnails() = runTest {
        server.on(
            "social.grain.gallery.getCameras",
            """{"cameras":[{"camera":"Leica M6","photoCount":120,"thumbs":["https://cdn.grain.test/thumb/1.jpg"]}]}""",
        )
        val response = client.getCameras()

        val camera = response.cameras!!.single()
        assertEquals("Leica M6", camera.camera)
        assertEquals(120, camera.photoCount)
        assertEquals(listOf("https://cdn.grain.test/thumb/1.jpg"), camera.thumbs)
        assertTrue(server.lastCall("social.grain.gallery.getCameras").params.isEmpty())
    }

    @Test
    fun getLocationsAsksForPinsOnlyWhenToldTo() = runTest {
        server.on(
            "social.grain.gallery.getLocations",
            """{"locations":[{"name":"Lisbon, PT","h3Index":"85390cb7fffffff","galleryCount":3}]}""",
        )
        val full = client.getLocations()
        assertEquals("Lisbon, PT", full.locations!!.single().name)
        assertEquals("85390cb7fffffff", full.locations!!.single().h3Index)
        assertTrue(full.locations!!.single().thumbs.isEmpty())
        assertTrue(server.lastCall("social.grain.gallery.getLocations").params.isEmpty())

        client.getLocations(pins = true)
        assertEquals(
            mapOf("pins" to "true"),
            server.lastCall("social.grain.gallery.getLocations").params,
        )
    }

    // MARK: - Profile

    @Test
    fun getActorProfileNamesOnlyTheActor() = runTest {
        server.on(
            "social.grain.actor.getProfile",
            json(Fixtures.profileDetailed(following = "at://follow/1")),
        )

        val signedOut = client.getActorProfile("alice.grain.social")
        assertEquals("did:plc:alice", signedOut.did)
        assertEquals(
            mapOf("actor" to "alice.grain.social"),
            server.lastCall("social.grain.actor.getProfile").params,
        )

        client.getActorProfile("alice.grain.social", auth = auth)
        assertEquals(
            mapOf("actor" to "alice.grain.social"),
            server.lastCall("social.grain.actor.getProfile").params,
        )
    }

    @Test
    fun followerAndFollowingListsPaginate() = runTest {
        server.on("social.grain.graph.getFollowers", """{"totalCount":3,"items":[]}""")
        server.on("social.grain.graph.getFollowing", """{"totalCount":4,"items":[]}""")

        assertEquals(3, client.getFollowers("did:plc:alice", cursor = "c").totalCount)
        assertEquals(
            mapOf(
                "actor" to "did:plc:alice",
                "limit" to "50",
                "cursor" to "c",
            ),
            server.lastCall("social.grain.graph.getFollowers").params,
        )

        assertEquals(4, client.getFollowing("did:plc:alice", limit = 10).totalCount)
        assertEquals(
            mapOf("actor" to "did:plc:alice", "limit" to "10"),
            server.lastCall("social.grain.graph.getFollowing").params,
        )
    }

    @Test
    fun galleryFavoritesAndKnownFollowersAddressTheirSubject() = runTest {
        server.on("social.grain.gallery.getFavorites", """{"totalCount":8,"items":[]}""")
        server.on("social.grain.graph.getKnownFollowers", """{"items":[]}""")

        assertEquals(8, client.getGalleryFavorites("at://x/gallery/1").totalCount)
        assertEquals(
            mapOf("gallery" to "at://x/gallery/1", "limit" to "50"),
            server.lastCall("social.grain.gallery.getFavorites").params,
        )

        client.getKnownFollowers("did:plc:alice")
        assertEquals(
            mapOf("actor" to "did:plc:alice"),
            server.lastCall("social.grain.graph.getKnownFollowers").params,
        )
    }

    // MARK: - Records

    @Test
    fun getRecordReadsTheRecordFromThePds() = runTest {
        server.on(
            "com.atproto.repo.getRecord",
            """{"uri":"at://did:plc:me/social.grain.actor.profile/self","cid":"cid1","value":{"a":1}}""",
        )
        val response = client.getRecord("at://did:plc:me/social.grain.actor.profile/self", auth)

        assertEquals("cid1", response.cid)
        assertEquals("1", response.value!!["a"]!!.jsonPrimitive.content)
        val call = server.lastCall("com.atproto.repo.getRecord")
        assertEquals(
            mapOf("repo" to "did:plc:me", "collection" to "social.grain.actor.profile", "rkey" to "self"),
            call.params,
        )
        assertNull("The PDS answers it itself", call.headers["atproto-proxy"])
    }

    @Test
    fun createRecordPostsCollectionRepoAndRecord() = runTest {
        server.on("com.atproto.repo.createRecord", """{"uri":"at://new","cid":"cid-new"}""")
        val response = client.createRecord(
            collection = "social.grain.favorite",
            repo = "did:plc:me",
            record = buildJsonObject { put("subject", "at://x") },
            auth = auth,
        )

        assertEquals("at://new", response.uri)
        val sent = body("com.atproto.repo.createRecord")
        assertEquals("social.grain.favorite", sent["collection"]!!.jsonPrimitive.content)
        assertEquals("did:plc:me", sent["repo"]!!.jsonPrimitive.content)
        assertEquals("at://x", sent["record"]!!.jsonObject["subject"]!!.jsonPrimitive.content)
        assertEquals("POST", server.lastCall("com.atproto.repo.createRecord").method)
    }

    @Test
    fun putRecordCarriesTheRkeyAndDefaultsTheRepoToTheAccount() = runTest {
        server.on("com.atproto.repo.putRecord", """{"uri":"at://put","cid":"cid"}""")

        client.putRecord("social.grain.actor.profile", "self", buildJsonObject { }, auth = auth)
        assertEquals("did:plc:me", body("com.atproto.repo.putRecord")["repo"]!!.jsonPrimitive.content)

        client.putRecord(
            "social.grain.actor.profile",
            "self",
            buildJsonObject { },
            repo = "did:plc:me",
            auth = auth,
        )
        val sent = body("com.atproto.repo.putRecord")
        assertEquals("self", sent["rkey"]!!.jsonPrimitive.content)
        assertEquals("did:plc:me", sent["repo"]!!.jsonPrimitive.content)
    }

    @Test
    fun deleteRecordNamesTheRepoCollectionAndRkey() = runTest {
        client.deleteRecord("social.grain.comment", "abc", auth)

        val sent = body("com.atproto.repo.deleteRecord")
        assertEquals("did:plc:me", sent["repo"]!!.jsonPrimitive.content)
        assertEquals("social.grain.comment", sent["collection"]!!.jsonPrimitive.content)
        assertEquals("abc", sent["rkey"]!!.jsonPrimitive.content)
    }

    // MARK: - Writes at the PDS, then indexed

    @Test
    fun aWriteGoesToThePdsItselfWithNoProxy() = runTest {
        server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/social.grain.favorite/f1"}""")

        client.createRecord("social.grain.favorite", "did:plc:me", buildJsonObject { }, auth)

        val write = server.lastCall("com.atproto.repo.createRecord")
        assertEquals("stub.grain.test", write.url.host)
        assertNull("The PDS takes its own writes", write.headers["atproto-proxy"])
        assertEquals("DPoP test-access-token", write.authorization)
    }

    @Test
    fun aCreatedRecordIsIndexedThroughTheProxy() = runTest {
        server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/social.grain.favorite/f1"}""")

        client.createRecord("social.grain.favorite", "did:plc:me", buildJsonObject { }, auth)

        val index = server.lastCall("social.grain.sync.indexRecords")
        assertEquals("POST", index.method)
        assertEquals(
            listOf("at://did:plc:me/social.grain.favorite/f1"),
            index.json["uris"]!!.jsonArray.map { it.jsonPrimitive.content },
        )
        assertEquals("did:web:stub.grain.test#grain_appview", index.headers["atproto-proxy"])
    }

    @Test
    fun aDeletedRecordIsNamedToTheIndexToo() = runTest {
        client.deleteRecord("social.grain.comment", "abc", auth)

        assertEquals(
            "at://did:plc:me/social.grain.comment/abc",
            server.lastCall("social.grain.sync.indexRecords").json["uris"]!!.jsonArray.single().jsonPrimitive.content,
        )
    }

    @Test
    fun aPutRecordIsIndexed() = runTest {
        server.on("com.atproto.repo.putRecord", """{"uri":"at://did:plc:me/social.grain.actor.profile/self"}""")

        client.putRecord("social.grain.actor.profile", "self", buildJsonObject { }, auth = auth)

        assertEquals(1, server.countOf("social.grain.sync.indexRecords"))
    }

    @Test
    fun aBatchIsIndexedTwentyFiveAtATime() = runTest {
        val writes = (1..30).map { ApplyWrite("social.grain.photo", "p$it", buildJsonObject { }) }

        client.applyWrites(writes, auth)

        val calls = server.callsTo("social.grain.sync.indexRecords")
        assertEquals(2, calls.size)
        assertEquals(25, calls[0].json["uris"]!!.jsonArray.size)
        assertEquals(5, calls[1].json["uris"]!!.jsonArray.size)
        assertEquals("at://did:plc:me/social.grain.photo/p1", calls[0].json["uris"]!!.jsonArray[0].jsonPrimitive.content)
        assertEquals("did:plc:me", body("com.atproto.repo.applyWrites")["repo"]!!.jsonPrimitive.content)
    }

    @Test
    fun anIndexThatFailsDoesNotFailTheWrite() = runTest {
        server
            .on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/social.grain.favorite/f1"}""")
            .on("social.grain.sync.indexRecords", """{"error":"InternalServerError"}""", code = 500)

        val response = client.createRecord("social.grain.favorite", "did:plc:me", buildJsonObject { }, auth)

        assertEquals("at://did:plc:me/social.grain.favorite/f1", response.uri)
    }

    @Test
    fun aWriteWithNoAccountIsUnauthorized() = runTest {
        assertFails<XrpcException.Unauthorized> {
            client.deleteRecord("social.grain.comment", "abc", null)
        }
        assertFails<XrpcException.Unauthorized> { client.applyWrites(emptyList(), null) }
    }

    @Test
    fun updatesAndDeletesInABatchCarryTheirOwnTypes() {
        val update = ApplyWrite("social.grain.gallery", "g1", buildJsonObject { put("title", "t") }, ApplyWrite.Action.Update)
        val delete = ApplyWrite("social.grain.gallery", "g2", buildJsonObject { put("title", "t") }, ApplyWrite.Action.Delete)

        assertEquals("com.atproto.repo.applyWrites#update", update.toJson()["\$type"]!!.jsonPrimitive.content)
        assertEquals("t", update.toJson()["value"]!!.jsonObject["title"]!!.jsonPrimitive.content)
        assertEquals("com.atproto.repo.applyWrites#delete", delete.toJson()["\$type"]!!.jsonPrimitive.content)
        assertNull("A delete carries no value", delete.toJson()["value"])
    }

    @Test
    fun applyWritesBatchesEveryCreateIntoOneCommit() = runTest {
        server.on("com.atproto.repo.applyWrites", """{"results":[{"uri":"at://a"},{"uri":"at://b"}]}""")
        val response = client.applyWrites(
            listOf(
                ApplyWrite("social.grain.photo", "p1", buildJsonObject { put("alt", "one") }),
                ApplyWrite("social.grain.gallery", "g1", buildJsonObject { put("title", "two") }),
            ),
            auth,
        )

        assertEquals(2, response.results?.size)
        val writes = body("com.atproto.repo.applyWrites")["writes"]!!.jsonArray
        assertEquals(2, writes.size)
        val first = writes[0].jsonObject
        assertEquals("com.atproto.repo.applyWrites#create", first["\$type"]!!.jsonPrimitive.content)
        assertEquals("social.grain.photo", first["collection"]!!.jsonPrimitive.content)
        assertEquals("p1", first["rkey"]!!.jsonPrimitive.content)
        assertEquals("one", first["value"]!!.jsonObject["alt"]!!.jsonPrimitive.content)
    }

    @Test
    fun deletingAGalleryDeletesItsRecordsAtThePdsChildrenFirst() = runTest {
        val me = "at://did:plc:me"
        val gallery = "$me/social.grain.gallery/g1"
        server.on("com.atproto.repo.listRecords") { call ->
            when (call.params["collection"]) {
                "social.grain.gallery.item" -> records(
                    """{"uri":"$me/social.grain.gallery.item/i1","value":{"gallery":"$gallery","item":"$me/social.grain.photo/p1"}}""",
                    """{"uri":"$me/social.grain.gallery.item/i2","value":{"gallery":"$gallery","item":"$me/social.grain.photo/p2"}}""",
                    """{"uri":"$me/social.grain.gallery.item/i3","value":{"gallery":"$me/social.grain.gallery/g2","item":"$me/social.grain.photo/p2"}}""",
                )
                "social.grain.photo.exif" -> records(
                    """{"uri":"$me/social.grain.photo.exif/e1","value":{"photo":"$me/social.grain.photo/p1"}}""",
                    """{"uri":"$me/social.grain.photo.exif/e2","value":{"photo":"$me/social.grain.photo/p2"}}""",
                )
                "social.grain.favorite" -> records(
                    """{"uri":"$me/social.grain.favorite/f1","value":{"subject":"$gallery"}}""",
                    """{"uri":"$me/social.grain.favorite/f2","value":{"subject":"$me/social.grain.gallery/g2"}}""",
                )
                "social.grain.comment" -> records(
                    """{"uri":"$me/social.grain.comment/c1","value":{"subject":"$gallery"}}""",
                )
                else -> records()
            }
        }

        client.deleteGallery("g1", auth)

        val writes = body("com.atproto.repo.applyWrites")["writes"]!!.jsonArray.map { it.jsonObject }
        assertTrue(writes.all { it["\$type"]!!.jsonPrimitive.content == "com.atproto.repo.applyWrites#delete" })
        assertEquals(
            "A photo another gallery places stays, with its exif",
            listOf(
                "social.grain.photo.exif/e1",
                "social.grain.gallery.item/i1",
                "social.grain.gallery.item/i2",
                "social.grain.photo/p1",
                "social.grain.favorite/f1",
                "social.grain.comment/c1",
                "social.grain.gallery/g1",
            ),
            writes.map { "${it["collection"]!!.jsonPrimitive.content}/${it["rkey"]!!.jsonPrimitive.content}" },
        )
        assertEquals(0, server.countOf("social.grain.gallery.deleteGallery"))
        val indexed = body("social.grain.sync.indexRecords")["uris"]!!.jsonArray
        assertTrue(indexed.any { it.jsonPrimitive.content == gallery })
    }

    @Test
    fun listingRecordsFollowsTheCursorToTheEnd() = runTest {
        server.on("com.atproto.repo.listRecords") { call ->
            if (call.params["cursor"] == null) {
                """{"records":[{"uri":"at://did:plc:me/social.grain.favorite/a"}],"cursor":"next"}"""
            } else {
                """{"records":[{"uri":"at://did:plc:me/social.grain.favorite/b"}]}"""
            }
        }
        val all = client.listAllRecords("social.grain.favorite", auth)
        assertEquals(listOf("a", "b"), all.map { it.uri.substringAfterLast('/') })
        assertEquals("did:plc:me", server.lastCall("com.atproto.repo.listRecords").params["repo"])
    }

    // MARK: - Moderation

    @Test
    fun reportingARecordSendsAStrongRef() = runTest {
        server.on("com.atproto.moderation.createReport", """{"id":42}""")
        val response = client.createReport(
            ReportSubject.Record("at://x/gallery/1", "cid1"),
            label = "spam",
            reason = "Repeated posts",
            auth = auth,
        )

        assertEquals(42, response.id)
        val sent = body("com.atproto.moderation.createReport")
        val subject = sent["subject"]!!.jsonObject
        assertEquals("com.atproto.repo.strongRef", subject["\$type"]!!.jsonPrimitive.content)
        assertEquals("at://x/gallery/1", subject["uri"]!!.jsonPrimitive.content)
        assertEquals("cid1", subject["cid"]!!.jsonPrimitive.content)
        assertEquals("com.atproto.moderation.defs#reasonSpam", sent["reasonType"]!!.jsonPrimitive.content)
        assertNull(sent["label"])
        assertEquals(
            "The PDS forwards the report to grain-serve",
            "did:web:stub.grain.test#grain_appview",
            server.lastCall("com.atproto.moderation.createReport").headers["atproto-proxy"],
        )
        assertEquals("Repeated posts", sent["reason"]!!.jsonPrimitive.content)
    }

    @Test
    fun reportingAnAccountSendsARepoRefAndNoReason() = runTest {
        server.on("com.atproto.moderation.createReport", """{"id":7}""")
        client.createReport(ReportSubject.Account("did:plc:bob"), label = "nudity", auth = auth)

        val sent = body("com.atproto.moderation.createReport")
        val subject = sent["subject"]!!.jsonObject
        assertEquals("com.atproto.admin.defs#repoRef", subject["\$type"]!!.jsonPrimitive.content)
        assertEquals("did:plc:bob", subject["did"]!!.jsonPrimitive.content)
        assertEquals("social.grain.moderation.defs#reasonNudity", sent["reasonType"]!!.jsonPrimitive.content)
        assertNull(sent["reason"])
    }

    // MARK: - Push

    @Test
    fun pushTokensRegisterAndUnregisterForThisPlatform() = runTest {
        client.registerPushToken("fcm-token", "social.grain.debug", auth)
        val registered = body("social.grain.notification.registerPush")
        assertEquals("fcm-token", registered["token"]!!.jsonPrimitive.content)
        assertEquals("android", registered["platform"]!!.jsonPrimitive.content)
        assertEquals("did:web:stub.grain.test", registered["serviceDid"]!!.jsonPrimitive.content)
        assertEquals("social.grain.debug", registered["appId"]!!.jsonPrimitive.content)

        client.unregisterPushToken("fcm-token", "social.grain.debug", auth)
        val unregistered = body("social.grain.notification.unregisterPush")
        assertEquals("fcm-token", unregistered["token"]!!.jsonPrimitive.content)
        assertEquals("did:web:stub.grain.test", unregistered["serviceDid"]!!.jsonPrimitive.content)
    }

    // MARK: - Moderation lists

    @Test
    fun blockAndMuteListsAreReadWholeAndAlwaysAuthenticated() = runTest {
        server.on(
            "social.grain.graph.getBlocks",
            """{"items":[{"did":"did:plc:bob","blockUri":"at://block/1"}]}""",
        )
        server.on("social.grain.graph.getMutes", """{"items":[{"did":"did:plc:carol"}]}""")

        assertEquals("at://block/1", client.getBlocks(auth).items!!.single().blockUri)
        assertEquals("did:plc:carol", client.getMutes(auth).items!!.single().did)

        assertTrue(
            server.lastCall("social.grain.graph.getBlocks")
                .authorization!!.startsWith("DPoP "),
        )
    }

    @Test
    fun mutingAndUnmutingNameTheActor() = runTest {
        client.muteActor("did:plc:bob", auth)
        assertEquals(
            "did:plc:bob",
            body("social.grain.graph.muteActor")["actor"]!!.jsonPrimitive.content,
        )

        client.unmuteActor("did:plc:bob", auth)
        assertEquals(
            "did:plc:bob",
            body("social.grain.graph.unmuteActor")["actor"]!!.jsonPrimitive.content,
        )
    }

    @Test
    fun blockServiceCreatesARecordAndDeletesByRkey() = runTest {
        server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/block/xyz"}""")
        BlockService.block("did:plc:bob", "did:plc:me", client, auth)

        val record = body("com.atproto.repo.createRecord")["record"]!!.jsonObject
        assertEquals("did:plc:bob", record["subject"]!!.jsonPrimitive.content)
        assertTrue(record.containsKey("createdAt"))

        BlockService.unblock("at://did:plc:me/social.grain.graph.block/xyz", client, auth)
        val deleted = body("com.atproto.repo.deleteRecord")
        assertEquals(BlockService.COLLECTION, deleted["collection"]!!.jsonPrimitive.content)
        assertEquals("xyz", deleted["rkey"]!!.jsonPrimitive.content)
    }

    @Test
    fun deletingTheAccountEmptiesEachGrainCollectionThenAsksToBeForgotten() = runTest {
        server.on(
            "com.atproto.repo.describeRepo",
            """{"collections":["app.bsky.feed.post","social.grain.favorite","social.grain.gallery"]}""",
        )
        server.on("com.atproto.repo.listRecords") { call ->
            when (call.params["collection"]) {
                "social.grain.favorite" -> records(
                    """{"uri":"at://did:plc:me/social.grain.favorite/f1"}""",
                    """{"uri":"at://did:plc:me/social.grain.favorite/f2"}""",
                )
                "social.grain.gallery" -> records("""{"uri":"at://did:plc:me/social.grain.gallery/g1"}""")
                else -> records("""{"uri":"at://did:plc:me/app.bsky.feed.post/p1"}""")
            }
        }
        server.on("social.grain.server.deleteAccount", """{"deleted":0,"done":true}""")

        assertTrue(client.deleteAccount(auth).done)

        assertEquals(
            "Only grain's collections are listed",
            listOf("social.grain.favorite", "social.grain.gallery"),
            server.callsTo("com.atproto.repo.listRecords").map { it.params["collection"] },
        )
        val deleted = server.callsTo("com.atproto.repo.applyWrites").flatMap { call ->
            call.json["writes"]!!.jsonArray.map { it.jsonObject["rkey"]!!.jsonPrimitive.content }
        }
        assertEquals(listOf("f1", "f2", "g1"), deleted)
        assertEquals("did:plc:me", body("social.grain.server.deleteAccount")["did"]!!.jsonPrimitive.content)
        val asked = server.calls.map { it.nsid }
        assertTrue(
            "grain-serve is asked once the records are gone",
            asked.indexOf("social.grain.server.deleteAccount") > asked.lastIndexOf("com.atproto.repo.applyWrites"),
        )
    }

    // MARK: - Services

    @Test
    fun favoriteAndFollowServicesWriteTheirOwnCollections() = runTest {
        server.on("com.atproto.repo.createRecord", """{"uri":"at://fav"}""")
        FavoriteService.create("at://x/gallery/1", "did:plc:me", client, auth)
        assertEquals(
            FavoriteService.COLLECTION,
            body("com.atproto.repo.createRecord")["collection"]!!.jsonPrimitive.content,
        )

        FavoriteService.delete("at://did:plc:me/social.grain.favorite/f1", client, auth)
        assertEquals("f1", body("com.atproto.repo.deleteRecord")["rkey"]!!.jsonPrimitive.content)

        FollowService.create("did:plc:bob", "did:plc:me", client, auth)
        val follow = body("com.atproto.repo.createRecord")
        assertEquals(FollowService.COLLECTION, follow["collection"]!!.jsonPrimitive.content)
        assertEquals(
            "did:plc:bob",
            follow["record"]!!.jsonObject["subject"]!!.jsonPrimitive.content,
        )

        FollowService.delete("at://did:plc:me/social.grain.graph.follow/fl1", client, auth)
        assertEquals("fl1", body("com.atproto.repo.deleteRecord")["rkey"]!!.jsonPrimitive.content)
    }

    @Test
    fun galleryServiceDeletesByTheRkeyInTheUri() = runTest {
        GalleryService.delete("at://did:plc:me/social.grain.gallery/g9", client, auth)
        val writes = body("com.atproto.repo.applyWrites")["writes"]!!.jsonArray
        assertEquals("g9", writes.last().jsonObject["rkey"]!!.jsonPrimitive.content)
    }

    @Test
    fun commentServiceCarriesTheReplyTargetOnlyWhenReplying() = runTest {
        server.on("com.atproto.repo.createRecord", """{"uri":"at://c"}""")

        CommentService.create("at://x/gallery/1", "Nice", "did:plc:me", client = client, auth = auth)
        val top = body("com.atproto.repo.createRecord")["record"]!!.jsonObject
        assertEquals("Nice", top["text"]!!.jsonPrimitive.content)
        assertEquals("at://x/gallery/1", top["subject"]!!.jsonPrimitive.content)
        assertNull(top["replyTo"])

        CommentService.create(
            "at://x/gallery/1",
            "Agreed",
            "did:plc:me",
            replyTo = "at://x/comment/1",
            client = client,
            auth = auth,
        )
        assertEquals(
            "at://x/comment/1",
            body("com.atproto.repo.createRecord")["record"]!!
                .jsonObject["replyTo"]!!.jsonPrimitive.content,
        )

        CommentService.delete("at://did:plc:me/social.grain.comment/c1", client, auth)
        assertEquals("c1", body("com.atproto.repo.deleteRecord")["rkey"]!!.jsonPrimitive.content)
    }

    @Test
    fun storyServiceBuildsTheMediaBlobAndAspectRatio() = runTest {
        server.on("com.atproto.repo.createRecord", """{"uri":"at://s"}""")
        StoryService.create(
            blob = BlobRef(
                type = "blob",
                ref = BlobRef.BlobLink("bafyabc"),
                mimeType = "image/jpeg",
                size = 1234,
            ),
            width = 1080,
            height = 1920,
            repo = "did:plc:me",
            client = client,
            auth = auth,
        )

        val record = body("com.atproto.repo.createRecord")["record"]!!.jsonObject
        val media = record["media"]!!.jsonObject
        assertEquals("bafyabc", media["ref"]!!.jsonObject["\$link"]!!.jsonPrimitive.content)
        assertEquals("image/jpeg", media["mimeType"]!!.jsonPrimitive.content)
        assertEquals("1234", media["size"]!!.jsonPrimitive.content)
        assertEquals("1080", record["aspectRatio"]!!.jsonObject["width"]!!.jsonPrimitive.content)
        assertNull(record["location"])
        assertNull(record["labels"])
    }

    @Test
    fun storyServiceAttachesLocationAndSelfLabelsWhenGiven() = runTest {
        server.on("com.atproto.repo.createRecord", """{"uri":"at://s"}""")
        StoryService.create(
            blob = BlobRef(ref = BlobRef.BlobLink("bafy")),
            width = 9,
            height = 16,
            repo = "did:plc:me",
            labels = listOf("nudity"),
            locationCell = "8928308280fffff",
            locationName = "Mission District",
            address = buildJsonObject { put("country", "US") },
            client = client,
            auth = auth,
        )

        val record = body("com.atproto.repo.createRecord")["record"]!!.jsonObject
        val location = record["location"]!!.jsonObject
        assertEquals("8928308280fffff", location["value"]!!.jsonPrimitive.content)
        assertEquals("Mission District", location["name"]!!.jsonPrimitive.content)
        assertEquals("US", record["address"]!!.jsonObject["country"]!!.jsonPrimitive.content)

        val labels = record["labels"]!!.jsonObject
        assertEquals(
            "com.atproto.label.defs#selfLabels",
            labels["\$type"]!!.jsonPrimitive.content,
        )
        assertEquals(
            "nudity",
            labels["values"]!!.jsonArray.single().jsonObject["val"]!!.jsonPrimitive.content,
        )
    }

    @Test
    fun storyServiceDeletesByRkey() = runTest {
        StoryService.delete("at://did:plc:me/social.grain.story/s1", client, auth)
        assertEquals("s1", body("com.atproto.repo.deleteRecord")["rkey"]!!.jsonPrimitive.content)
    }

    @Test
    fun storyServiceFallsBackWhenTheBlobIsMissingItsFields() = runTest {
        server.on("com.atproto.repo.createRecord", """{"uri":"at://s"}""")
        StoryService.create(
            blob = BlobRef(),
            width = 1,
            height = 1,
            repo = "did:plc:me",
            client = client,
            auth = auth,
        )

        val media = body("com.atproto.repo.createRecord")["record"]!!.jsonObject["media"]!!.jsonObject
        assertEquals("blob", media["\$type"]!!.jsonPrimitive.content)
        assertEquals("", media["ref"]!!.jsonObject["\$link"]!!.jsonPrimitive.content)
        assertEquals("image/jpeg", media["mimeType"]!!.jsonPrimitive.content)
        assertEquals("0", media["size"]!!.jsonPrimitive.content)
    }

    // MARK: - Pinned feeds

    @Test
    fun aPinnedFeedNamesItselfByTypeOnlyWhenItIsACompoundId() = runTest {
        val camera = PinnedFeed("camera:Leica M6", "Leica M6", "camera", "/c")
        assertEquals("camera", camera.feedName)
        assertEquals("Leica M6", camera.feedValue)

        val recent = PinnedFeed.defaults.first()
        assertEquals("recent", recent.feedName)
        assertNull(recent.feedValue)

        val hashtag = PinnedFeed("hashtag:fog", "#fog", "hashtag", "/t/fog")
        assertEquals("hashtag", hashtag.feedName)
        assertEquals("fog", hashtag.feedValue)
    }

    private val PREFERENCES = """{"preferences":[""" +
        """{"${'$'}type":"social.grain.actor.defs#pinnedFeedsPref","items":[{"id":"camera:Leica M6",""" +
        """"label":"Leica M6","type":"camera","path":"/cameras/leica"}]},""" +
        """{"${'$'}type":"social.grain.actor.defs#photoMetadataPref","includeExif":true},""" +
        """{"${'$'}type":"social.grain.actor.defs#themePref","theme":"dark"},""" +
        """{"${'$'}type":"social.grain.actor.defs#somethingNew","x":1}]}"""

    /** A `com.atproto.repo.listRecords` page holding [records], with no cursor. */
    private fun records(vararg records: String) = """{"records":[${records.joinToString(",")}]}"""
}