Skip to content

Commit ea145d3

Browse files
Begin adding some unit tests for Bluesky models.
1 parent f26d1b2 commit ea145d3

File tree

10 files changed

+350
-5
lines changed

10 files changed

+350
-5
lines changed

api-gen-runtime-internal/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ kotlin {
1414
sourceSets {
1515
val commonMain by getting {
1616
dependencies {
17-
api(libs.kotlinx.coroutines)
17+
api(libs.kotlinx.coroutines.core)
1818
api(libs.kotlinx.serialization.core)
1919
api(libs.ktor.core)
2020

app/common/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ kotlin {
4343
implementation(libs.kamel)
4444
implementation(libs.kotlininject)
4545
implementation(libs.kotlinx.atomicfu)
46-
implementation(libs.kotlinx.coroutines)
46+
implementation(libs.kotlinx.coroutines.core)
4747
implementation(libs.ktor.logging)
4848

4949
api(project(":bluesky"))

app/store/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ kotlin {
1616
sourceSets {
1717
val commonMain by getting {
1818
dependencies {
19-
api(libs.kotlinx.coroutines)
19+
api(libs.kotlinx.coroutines.core)
2020

2121
implementation(libs.kotlinx.serialization.json)
2222
implementation(libs.multiplatform.settings)

bluesky/api/bluesky.api

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14767,6 +14767,7 @@ public final class sh/christian/ozone/XrpcBlueskyApi : sh/christian/ozone/Bluesk
1476714767
}
1476814768

1476914769
public final class sh/christian/ozone/api/AuthenticatedXrpcBlueskyApi : sh/christian/ozone/BlueskyApi {
14770+
public static final field Companion Lsh/christian/ozone/api/AuthenticatedXrpcBlueskyApi$Companion;
1477014771
public fun <init> (Lio/ktor/client/HttpClient;Lsh/christian/ozone/api/BlueskyAuthPlugin$Tokens;)V
1477114772
public synthetic fun <init> (Lio/ktor/client/HttpClient;Lsh/christian/ozone/api/BlueskyAuthPlugin$Tokens;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
1477214773
public synthetic fun <init> (Lsh/christian/ozone/XrpcBlueskyApi;Lkotlinx/coroutines/flow/MutableStateFlow;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
@@ -14956,6 +14957,11 @@ public final class sh/christian/ozone/api/AuthenticatedXrpcBlueskyApi : sh/chris
1495614957
public fun upsertSet (Ltools/ozone/set/Set;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
1495714958
}
1495814959

14960+
public final class sh/christian/ozone/api/AuthenticatedXrpcBlueskyApi$Companion {
14961+
public final fun authenticated (Lsh/christian/ozone/XrpcBlueskyApi;Lsh/christian/ozone/api/BlueskyAuthPlugin$Tokens;)Lsh/christian/ozone/api/AuthenticatedXrpcBlueskyApi;
14962+
public static synthetic fun authenticated$default (Lsh/christian/ozone/api/AuthenticatedXrpcBlueskyApi$Companion;Lsh/christian/ozone/XrpcBlueskyApi;Lsh/christian/ozone/api/BlueskyAuthPlugin$Tokens;ILjava/lang/Object;)Lsh/christian/ozone/api/AuthenticatedXrpcBlueskyApi;
14963+
}
14964+
1495914965
public final class sh/christian/ozone/api/BlueskyAuthPlugin {
1496014966
public static final field Companion Lsh/christian/ozone/api/BlueskyAuthPlugin$Companion;
1496114967
public fun <init> (Lkotlinx/serialization/json/Json;Lkotlinx/coroutines/flow/MutableStateFlow;)V

bluesky/build.gradle.kts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,15 @@ tasks.apiCheck.configure { dependsOn(generateLexicons) }
5656
tasks.withType<AbstractDokkaTask>().configureEach {
5757
dependsOn(tasks.withType<KotlinCompile>())
5858
}
59+
60+
kotlin {
61+
sourceSets {
62+
val commonTest by getting {
63+
dependencies {
64+
implementation(libs.kotlinx.coroutines.test)
65+
implementation(libs.ktor.test)
66+
implementation(kotlin("test"))
67+
}
68+
}
69+
}
70+
}

bluesky/src/commonMain/kotlin/sh/christian/ozone/api/AuthenticatedXrpcBlueskyApi.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ private constructor(
103103
}
104104
}
105105

106-
private companion object {
106+
companion object {
107107
/**
108108
* Wraps an [XrpcBlueskyApi] instance as an [AuthenticatedXrpcBlueskyApi] with the optional initial tokens.
109109
*/
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package sh.christian.ozone.api
2+
3+
import com.atproto.server.DescribeServerLinks
4+
import com.atproto.server.DescribeServerResponse
5+
import io.ktor.client.HttpClient
6+
import io.ktor.http.HttpStatusCode.Companion.BadRequest
7+
import io.ktor.http.HttpStatusCode.Companion.OK
8+
import kotlinx.coroutines.test.runTest
9+
import sh.christian.ozone.XrpcBlueskyApi
10+
import sh.christian.ozone.api.response.AtpErrorDescription
11+
import sh.christian.ozone.api.response.AtpResponse
12+
import sh.christian.ozone.api.response.StatusCode.InvalidRequest
13+
import kotlin.test.Test
14+
import kotlin.test.assertEquals
15+
import kotlin.test.assertIs
16+
import kotlin.test.assertNull
17+
18+
class AtpErrorsTest {
19+
20+
private val describeServerResponse = DescribeServerResponse(
21+
did = Did("did:web:bsky.social"),
22+
availableUserDomains = listOf(".bsky.social"),
23+
inviteCodeRequired = false,
24+
phoneVerificationRequired = true,
25+
links = DescribeServerLinks(
26+
privacyPolicy = Uri("https://blueskyweb.xyz/support/privacy-policy"),
27+
termsOfService = Uri("https://blueskyweb.xyz/support/tos"),
28+
),
29+
)
30+
31+
private val errorDescription = AtpErrorDescription(
32+
error = "AuthFactorTokenRequired",
33+
message = "Needs 2FA",
34+
)
35+
36+
@Test
37+
fun testSuccessHasNoError() = runTest {
38+
val api = XrpcBlueskyApi(HttpClient(mockEngine(statusCode = OK, response = describeServerResponse)))
39+
40+
val response = api.describeServer()
41+
assertIs<AtpResponse.Success<DescribeServerResponse>>(response)
42+
assertEquals(response.response, describeServerResponse)
43+
}
44+
45+
@Test
46+
fun testEmptyError() = runTest {
47+
val api = XrpcBlueskyApi(HttpClient(mockEngine(statusCode = BadRequest, response = "")))
48+
49+
val response = api.describeServer()
50+
assertIs<AtpResponse.Failure<DescribeServerResponse>>(response)
51+
assertNull(response.response)
52+
assertNull(response.error)
53+
}
54+
55+
@Test
56+
fun testErrorKeepsStatusCode() = runTest {
57+
val api = XrpcBlueskyApi(HttpClient(mockEngine(statusCode = BadRequest, response = "")))
58+
59+
val response = api.describeServer()
60+
assertIs<AtpResponse.Failure<DescribeServerResponse>>(response)
61+
assertEquals(InvalidRequest, response.statusCode)
62+
}
63+
64+
@Test
65+
fun testErrorKeepsHeaders() = runTest {
66+
val api = XrpcBlueskyApi(HttpClient(mockEngine(statusCode = BadRequest, response = "")))
67+
68+
val response = api.describeServer()
69+
assertIs<AtpResponse.Failure<DescribeServerResponse>>(response)
70+
assertEquals(mapOf("Content-Type" to "application/json"), response.headers)
71+
}
72+
73+
@Test
74+
fun testFailureWithResponse() = runTest {
75+
val api = XrpcBlueskyApi(HttpClient(mockEngine(statusCode = BadRequest, response = describeServerResponse)))
76+
77+
val response = api.describeServer()
78+
assertIs<AtpResponse.Failure<DescribeServerResponse>>(response)
79+
assertEquals(InvalidRequest, response.statusCode)
80+
assertNull(response.error)
81+
assertEquals(response.response, describeServerResponse)
82+
}
83+
84+
@Test
85+
fun testFailureWithAtpErrorDescription() = runTest {
86+
val api = XrpcBlueskyApi(HttpClient(mockEngine(statusCode = BadRequest, error = errorDescription)))
87+
88+
val response = api.describeServer()
89+
assertIs<AtpResponse.Failure<DescribeServerResponse>>(response)
90+
assertEquals(InvalidRequest, response.statusCode)
91+
assertEquals(response.error, errorDescription)
92+
assertNull(response.response)
93+
}
94+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package sh.christian.ozone.api
2+
3+
import com.atproto.server.CreateAccountRequest
4+
import com.atproto.server.CreateAccountResponse
5+
import com.atproto.server.CreateSessionRequest
6+
import com.atproto.server.CreateSessionResponse
7+
import com.atproto.server.RefreshSessionResponse
8+
import io.ktor.client.HttpClient
9+
import kotlinx.coroutines.test.runTest
10+
import kotlinx.serialization.encodeToString
11+
import sh.christian.ozone.BlueskyJson
12+
import sh.christian.ozone.XrpcBlueskyApi
13+
import sh.christian.ozone.api.AuthenticatedXrpcBlueskyApi.Companion.authenticated
14+
import sh.christian.ozone.api.response.AtpErrorDescription
15+
import kotlin.test.Test
16+
import kotlin.test.assertEquals
17+
import kotlin.test.assertNull
18+
19+
class AuthenticatedXrpcBlueskyApiTest {
20+
21+
@Test
22+
fun testInitialTokensFromConstructor() = runTest {
23+
val tokens = BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt")
24+
val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine("")), tokens)
25+
assertEquals(tokens, api.authTokens.value)
26+
}
27+
28+
@Test
29+
fun testInitialTokensFromFactory() = runTest {
30+
val tokens = BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt")
31+
val api = XrpcBlueskyApi(HttpClient(mockEngine(""))).authenticated(tokens)
32+
assertEquals(tokens, api.authTokens.value)
33+
}
34+
35+
@Test
36+
fun testCreateAccount() = runTest {
37+
val request = CreateAccountRequest(
38+
email = "[email protected]",
39+
handle = Handle("bob.bsky.social"),
40+
password = "password",
41+
)
42+
val response = CreateAccountResponse(
43+
accessJwt = "accessJwt",
44+
refreshJwt = "refreshJwt",
45+
handle = Handle("bob.bsky.social"),
46+
did = Did("did:plc:123"),
47+
)
48+
49+
val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine(response)))
50+
assertNull(api.authTokens.value)
51+
api.createAccount(request)
52+
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt"), api.authTokens.value)
53+
}
54+
55+
@Test
56+
fun testCreateSession() = runTest {
57+
val request = CreateSessionRequest(
58+
identifier = "bob.bsky.social",
59+
password = "password",
60+
)
61+
val response = CreateSessionResponse(
62+
accessJwt = "accessJwt",
63+
refreshJwt = "refreshJwt",
64+
handle = Handle("bob.bsky.social"),
65+
did = Did("did:plc:123"),
66+
)
67+
68+
val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine(response)))
69+
assertNull(api.authTokens.value)
70+
api.createSession(request)
71+
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt"), api.authTokens.value)
72+
}
73+
74+
@Test
75+
fun testRefreshSession() = runTest {
76+
val response = RefreshSessionResponse(
77+
accessJwt = "accessJwt",
78+
refreshJwt = "refreshJwt",
79+
handle = Handle("bob.bsky.social"),
80+
did = Did("did:plc:123"),
81+
)
82+
83+
val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine(response)))
84+
assertNull(api.authTokens.value)
85+
api.refreshSession()
86+
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt"), api.authTokens.value)
87+
}
88+
89+
@Test
90+
fun testCreateThenRefreshSession() = runTest {
91+
val createRequest = CreateSessionRequest(
92+
identifier = "bob.bsky.social",
93+
password = "password",
94+
)
95+
val createResponse = CreateSessionResponse(
96+
accessJwt = "accessJwt-1",
97+
refreshJwt = "refreshJwt-1",
98+
handle = Handle("bob.bsky.social"),
99+
did = Did("did:plc:123"),
100+
)
101+
val refreshResponse = RefreshSessionResponse(
102+
accessJwt = "accessJwt-2",
103+
refreshJwt = "refreshJwt-2",
104+
handle = Handle("bob.bsky.social"),
105+
did = Did("did:plc:123"),
106+
)
107+
108+
val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine { request ->
109+
when (request.url.encodedPath) {
110+
"/xrpc/com.atproto.server.createSession" -> BlueskyJson.encodeToString(createResponse)
111+
"/xrpc/com.atproto.server.refreshSession" -> BlueskyJson.encodeToString(refreshResponse)
112+
else -> error("Unexpected request: ${request.url}")
113+
}
114+
}))
115+
116+
assertNull(api.authTokens.value)
117+
api.createSession(createRequest)
118+
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt-1", "refreshJwt-1"), api.authTokens.value)
119+
api.refreshSession()
120+
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt-2", "refreshJwt-2"), api.authTokens.value)
121+
}
122+
123+
@Test
124+
fun testDeleteSession() = runTest {
125+
val createRequest = CreateSessionRequest(
126+
identifier = "bob.bsky.social",
127+
password = "password",
128+
)
129+
val createResponse = CreateSessionResponse(
130+
accessJwt = "accessJwt",
131+
refreshJwt = "refreshJwt",
132+
handle = Handle("bob.bsky.social"),
133+
did = Did("did:plc:123"),
134+
)
135+
136+
val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine { request ->
137+
when (request.url.encodedPath) {
138+
"/xrpc/com.atproto.server.createSession" -> BlueskyJson.encodeToString(createResponse)
139+
"/xrpc/com.atproto.server.deleteSession" -> ""
140+
else -> error("Unexpected request: ${request.url}")
141+
}
142+
}))
143+
144+
assertNull(api.authTokens.value)
145+
api.createSession(createRequest)
146+
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt"), api.authTokens.value)
147+
api.deleteSession()
148+
assertNull(api.authTokens.value)
149+
}
150+
151+
@Test
152+
fun testClearCredentials() = runTest {
153+
val createRequest = CreateSessionRequest(
154+
identifier = "bob.bsky.social",
155+
password = "password",
156+
)
157+
val createResponse = CreateSessionResponse(
158+
accessJwt = "accessJwt",
159+
refreshJwt = "refreshJwt",
160+
handle = Handle("bob.bsky.social"),
161+
did = Did("did:plc:123"),
162+
)
163+
164+
val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine(createResponse)))
165+
166+
assertNull(api.authTokens.value)
167+
api.createSession(createRequest)
168+
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt"), api.authTokens.value)
169+
api.clearCredentials()
170+
assertNull(api.authTokens.value)
171+
}
172+
173+
@Test
174+
fun testFailingNetworkCallDoesNotSave() = runTest {
175+
val createRequest = CreateSessionRequest(
176+
identifier = "bob.bsky.social",
177+
password = "password",
178+
)
179+
val error = AtpErrorDescription(
180+
error = "AuthFactorTokenRequired",
181+
message = "Needs 2FA",
182+
)
183+
184+
val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine(error)))
185+
186+
assertNull(api.authTokens.value)
187+
api.createSession(createRequest)
188+
assertNull(api.authTokens.value)
189+
}
190+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package sh.christian.ozone.api
2+
3+
import io.ktor.client.engine.mock.MockEngine
4+
import io.ktor.client.engine.mock.respond
5+
import io.ktor.client.request.HttpRequestData
6+
import io.ktor.http.HttpHeaders
7+
import io.ktor.http.HttpStatusCode
8+
import io.ktor.http.headersOf
9+
import io.ktor.utils.io.ByteReadChannel
10+
import kotlinx.serialization.Serializable
11+
import kotlinx.serialization.encodeToString
12+
import sh.christian.ozone.BlueskyJson
13+
import sh.christian.ozone.api.response.AtpErrorDescription
14+
15+
inline fun <reified T : @Serializable Any> mockEngine(
16+
response: T,
17+
statusCode: HttpStatusCode = HttpStatusCode.OK,
18+
): MockEngine {
19+
return mockEngine(statusCode = statusCode) { BlueskyJson.encodeToString(response) }
20+
}
21+
22+
inline fun mockEngine(
23+
error: AtpErrorDescription,
24+
statusCode: HttpStatusCode = HttpStatusCode.BadRequest,
25+
): MockEngine {
26+
return mockEngine(response = error, statusCode = statusCode)
27+
}
28+
29+
inline fun mockEngine(
30+
statusCode: HttpStatusCode = HttpStatusCode.OK,
31+
noinline responseProvider: (HttpRequestData) -> String,
32+
): MockEngine {
33+
return MockEngine { request ->
34+
respond(
35+
content = ByteReadChannel(responseProvider(request)),
36+
status = statusCode,
37+
headers = headersOf(HttpHeaders.ContentType, "application/json"),
38+
)
39+
}
40+
}

0 commit comments

Comments
 (0)