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 { client.deleteRecord("social.grain.comment", "abc", null) } assertFails { 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(",")}]}""" }