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

grain.social/grain-android

1 collaborator
0
1
main
114 lines · 4.3 KB Raw History
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 },
        )
    }
}