package social.grain import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import social.grain.data.api.XrpcClient import social.grain.data.api.getActorProfile import social.grain.data.api.getFeed import social.grain.data.auth.AuthManager import java.util.concurrent.TimeUnit /** * Decodes live appview responses into the ported model types. * * The models were transcribed from Swift structs, and a field typed `Int` that * the server actually sends as a string — or a required field the server omits * — fails only at runtime, on a real response, as an empty feed with a decode * error in the log. Reading it off the wire is the only way to know. */ @RunWith(AndroidJUnit4::class) class FeedDecodeTest { private val json = Json { ignoreUnknownKeys = true explicitNulls = false coerceInputValues = true } private val client = XrpcClient( baseUrl = AuthManager.SERVER_URL, http = OkHttpClient.Builder() .connectTimeout(20, TimeUnit.SECONDS) .readTimeout(20, TimeUnit.SECONDS) .build(), json = json, ) @Test fun recentFeedDecodesIntoGalleryModels() = runBlocking { val response = client.getFeed(feed = "recent", limit = 20) val galleries = response.items.orEmpty() assertFalse("The recent feed came back empty", galleries.isEmpty()) for (gallery in galleries) { assertTrue("Gallery URI should be an at:// URI", gallery.uri.startsWith("at://")) assertTrue("Creator DID missing", gallery.creator.did.startsWith("did:")) // rkey/shareUrl are derived, so a malformed URI shows up here rather // than as a broken share sheet later. assertTrue(gallery.rkey.isNotEmpty()) assertTrue(gallery.shareUrl.startsWith("https://grain.social/profile/")) for (photo in gallery.items.orEmpty()) { assertTrue("Photo has no image URL", photo.thumb.isNotEmpty()) assertTrue("Aspect ratio should be positive", photo.aspectRatio.ratio > 0f) // The EXIF view type is where the Swift-to-Kotlin transcription // is densest; touching every computed property exercises it. photo.exif?.let { it.cameraName it.lensName it.settingsLine it.formattedFNumber it.formattedExposureTime it.formattedFocalLength } } } } @Test fun feedPaginatesWithACursor() = runBlocking { val first = client.getFeed(feed = "recent", limit = 5) val cursor = first.cursor assertNotNull("Recent feed returned no cursor to page with", cursor) val second = client.getFeed(feed = "recent", limit = 5, cursor = cursor) val firstUris = first.items.orEmpty().map { it.uri }.toSet() val secondUris = second.items.orEmpty().map { it.uri }.toSet() assertFalse("Second page came back empty", secondUris.isEmpty()) assertTrue( "Pages overlap — the cursor isn't being applied", firstUris.intersect(secondUris).isEmpty(), ) } @Test fun actorProfileDecodesIntoDetailedProfile() = runBlocking { // Resolve a real DID off the feed rather than hardcoding an account // that might be deleted or renamed later. val did = client.getFeed(feed = "recent", limit = 1) .items.orEmpty().first().creator.did val profile = client.getActorProfile(actor = did) assertTrue(profile.did.startsWith("did:")) assertTrue("Profile handle missing", profile.handle.isNotEmpty()) } @Test fun actorFeedDecodesForASingleAuthor() = runBlocking { val did = client.getFeed(feed = "recent", limit = 1) .items.orEmpty().first().creator.did val galleries = client.getFeed(feed = "actor", actor = did).items.orEmpty() assertTrue( "An author's own feed should only contain their galleries", galleries.all { it.creator.did == did }, ) } }