1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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 },
)
}
}