Skip to content

Commit

Permalink
Begin adding some unit tests for Bluesky models.
Browse files Browse the repository at this point in the history
  • Loading branch information
christiandeange committed Dec 28, 2024
1 parent f26d1b2 commit ea145d3
Show file tree
Hide file tree
Showing 10 changed files with 350 additions and 5 deletions.
2 changes: 1 addition & 1 deletion api-gen-runtime-internal/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ kotlin {
sourceSets {
val commonMain by getting {
dependencies {
api(libs.kotlinx.coroutines)
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.ktor.core)

Expand Down
2 changes: 1 addition & 1 deletion app/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ kotlin {
implementation(libs.kamel)
implementation(libs.kotlininject)
implementation(libs.kotlinx.atomicfu)
implementation(libs.kotlinx.coroutines)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.logging)

api(project(":bluesky"))
Expand Down
2 changes: 1 addition & 1 deletion app/store/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ kotlin {
sourceSets {
val commonMain by getting {
dependencies {
api(libs.kotlinx.coroutines)
api(libs.kotlinx.coroutines.core)

implementation(libs.kotlinx.serialization.json)
implementation(libs.multiplatform.settings)
Expand Down
6 changes: 6 additions & 0 deletions bluesky/api/bluesky.api
Original file line number Diff line number Diff line change
Expand Up @@ -14767,6 +14767,7 @@ public final class sh/christian/ozone/XrpcBlueskyApi : sh/christian/ozone/Bluesk
}

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

public final class sh/christian/ozone/api/AuthenticatedXrpcBlueskyApi$Companion {
public final fun authenticated (Lsh/christian/ozone/XrpcBlueskyApi;Lsh/christian/ozone/api/BlueskyAuthPlugin$Tokens;)Lsh/christian/ozone/api/AuthenticatedXrpcBlueskyApi;
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;
}

public final class sh/christian/ozone/api/BlueskyAuthPlugin {
public static final field Companion Lsh/christian/ozone/api/BlueskyAuthPlugin$Companion;
public fun <init> (Lkotlinx/serialization/json/Json;Lkotlinx/coroutines/flow/MutableStateFlow;)V
Expand Down
12 changes: 12 additions & 0 deletions bluesky/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,15 @@ tasks.apiCheck.configure { dependsOn(generateLexicons) }
tasks.withType<AbstractDokkaTask>().configureEach {
dependsOn(tasks.withType<KotlinCompile>())
}

kotlin {
sourceSets {
val commonTest by getting {
dependencies {
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.test)
implementation(kotlin("test"))
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ private constructor(
}
}

private companion object {
companion object {
/**
* Wraps an [XrpcBlueskyApi] instance as an [AuthenticatedXrpcBlueskyApi] with the optional initial tokens.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package sh.christian.ozone.api

import com.atproto.server.DescribeServerLinks
import com.atproto.server.DescribeServerResponse
import io.ktor.client.HttpClient
import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.HttpStatusCode.Companion.OK
import kotlinx.coroutines.test.runTest
import sh.christian.ozone.XrpcBlueskyApi
import sh.christian.ozone.api.response.AtpErrorDescription
import sh.christian.ozone.api.response.AtpResponse
import sh.christian.ozone.api.response.StatusCode.InvalidRequest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull

class AtpErrorsTest {

private val describeServerResponse = DescribeServerResponse(
did = Did("did:web:bsky.social"),
availableUserDomains = listOf(".bsky.social"),
inviteCodeRequired = false,
phoneVerificationRequired = true,
links = DescribeServerLinks(
privacyPolicy = Uri("https://blueskyweb.xyz/support/privacy-policy"),
termsOfService = Uri("https://blueskyweb.xyz/support/tos"),
),
)

private val errorDescription = AtpErrorDescription(
error = "AuthFactorTokenRequired",
message = "Needs 2FA",
)

@Test
fun testSuccessHasNoError() = runTest {
val api = XrpcBlueskyApi(HttpClient(mockEngine(statusCode = OK, response = describeServerResponse)))

val response = api.describeServer()
assertIs<AtpResponse.Success<DescribeServerResponse>>(response)
assertEquals(response.response, describeServerResponse)
}

@Test
fun testEmptyError() = runTest {
val api = XrpcBlueskyApi(HttpClient(mockEngine(statusCode = BadRequest, response = "")))

val response = api.describeServer()
assertIs<AtpResponse.Failure<DescribeServerResponse>>(response)
assertNull(response.response)
assertNull(response.error)
}

@Test
fun testErrorKeepsStatusCode() = runTest {
val api = XrpcBlueskyApi(HttpClient(mockEngine(statusCode = BadRequest, response = "")))

val response = api.describeServer()
assertIs<AtpResponse.Failure<DescribeServerResponse>>(response)
assertEquals(InvalidRequest, response.statusCode)
}

@Test
fun testErrorKeepsHeaders() = runTest {
val api = XrpcBlueskyApi(HttpClient(mockEngine(statusCode = BadRequest, response = "")))

val response = api.describeServer()
assertIs<AtpResponse.Failure<DescribeServerResponse>>(response)
assertEquals(mapOf("Content-Type" to "application/json"), response.headers)
}

@Test
fun testFailureWithResponse() = runTest {
val api = XrpcBlueskyApi(HttpClient(mockEngine(statusCode = BadRequest, response = describeServerResponse)))

val response = api.describeServer()
assertIs<AtpResponse.Failure<DescribeServerResponse>>(response)
assertEquals(InvalidRequest, response.statusCode)
assertNull(response.error)
assertEquals(response.response, describeServerResponse)
}

@Test
fun testFailureWithAtpErrorDescription() = runTest {
val api = XrpcBlueskyApi(HttpClient(mockEngine(statusCode = BadRequest, error = errorDescription)))

val response = api.describeServer()
assertIs<AtpResponse.Failure<DescribeServerResponse>>(response)
assertEquals(InvalidRequest, response.statusCode)
assertEquals(response.error, errorDescription)
assertNull(response.response)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package sh.christian.ozone.api

import com.atproto.server.CreateAccountRequest
import com.atproto.server.CreateAccountResponse
import com.atproto.server.CreateSessionRequest
import com.atproto.server.CreateSessionResponse
import com.atproto.server.RefreshSessionResponse
import io.ktor.client.HttpClient
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.encodeToString
import sh.christian.ozone.BlueskyJson
import sh.christian.ozone.XrpcBlueskyApi
import sh.christian.ozone.api.AuthenticatedXrpcBlueskyApi.Companion.authenticated
import sh.christian.ozone.api.response.AtpErrorDescription
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class AuthenticatedXrpcBlueskyApiTest {

@Test
fun testInitialTokensFromConstructor() = runTest {
val tokens = BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt")
val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine("")), tokens)
assertEquals(tokens, api.authTokens.value)
}

@Test
fun testInitialTokensFromFactory() = runTest {
val tokens = BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt")
val api = XrpcBlueskyApi(HttpClient(mockEngine(""))).authenticated(tokens)
assertEquals(tokens, api.authTokens.value)
}

@Test
fun testCreateAccount() = runTest {
val request = CreateAccountRequest(
email = "bob@gmail.com",
handle = Handle("bob.bsky.social"),
password = "password",
)
val response = CreateAccountResponse(
accessJwt = "accessJwt",
refreshJwt = "refreshJwt",
handle = Handle("bob.bsky.social"),
did = Did("did:plc:123"),
)

val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine(response)))
assertNull(api.authTokens.value)
api.createAccount(request)
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt"), api.authTokens.value)
}

@Test
fun testCreateSession() = runTest {
val request = CreateSessionRequest(
identifier = "bob.bsky.social",
password = "password",
)
val response = CreateSessionResponse(
accessJwt = "accessJwt",
refreshJwt = "refreshJwt",
handle = Handle("bob.bsky.social"),
did = Did("did:plc:123"),
)

val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine(response)))
assertNull(api.authTokens.value)
api.createSession(request)
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt"), api.authTokens.value)
}

@Test
fun testRefreshSession() = runTest {
val response = RefreshSessionResponse(
accessJwt = "accessJwt",
refreshJwt = "refreshJwt",
handle = Handle("bob.bsky.social"),
did = Did("did:plc:123"),
)

val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine(response)))
assertNull(api.authTokens.value)
api.refreshSession()
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt"), api.authTokens.value)
}

@Test
fun testCreateThenRefreshSession() = runTest {
val createRequest = CreateSessionRequest(
identifier = "bob.bsky.social",
password = "password",
)
val createResponse = CreateSessionResponse(
accessJwt = "accessJwt-1",
refreshJwt = "refreshJwt-1",
handle = Handle("bob.bsky.social"),
did = Did("did:plc:123"),
)
val refreshResponse = RefreshSessionResponse(
accessJwt = "accessJwt-2",
refreshJwt = "refreshJwt-2",
handle = Handle("bob.bsky.social"),
did = Did("did:plc:123"),
)

val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine { request ->
when (request.url.encodedPath) {
"/xrpc/com.atproto.server.createSession" -> BlueskyJson.encodeToString(createResponse)
"/xrpc/com.atproto.server.refreshSession" -> BlueskyJson.encodeToString(refreshResponse)
else -> error("Unexpected request: ${request.url}")
}
}))

assertNull(api.authTokens.value)
api.createSession(createRequest)
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt-1", "refreshJwt-1"), api.authTokens.value)
api.refreshSession()
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt-2", "refreshJwt-2"), api.authTokens.value)
}

@Test
fun testDeleteSession() = runTest {
val createRequest = CreateSessionRequest(
identifier = "bob.bsky.social",
password = "password",
)
val createResponse = CreateSessionResponse(
accessJwt = "accessJwt",
refreshJwt = "refreshJwt",
handle = Handle("bob.bsky.social"),
did = Did("did:plc:123"),
)

val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine { request ->
when (request.url.encodedPath) {
"/xrpc/com.atproto.server.createSession" -> BlueskyJson.encodeToString(createResponse)
"/xrpc/com.atproto.server.deleteSession" -> ""
else -> error("Unexpected request: ${request.url}")
}
}))

assertNull(api.authTokens.value)
api.createSession(createRequest)
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt"), api.authTokens.value)
api.deleteSession()
assertNull(api.authTokens.value)
}

@Test
fun testClearCredentials() = runTest {
val createRequest = CreateSessionRequest(
identifier = "bob.bsky.social",
password = "password",
)
val createResponse = CreateSessionResponse(
accessJwt = "accessJwt",
refreshJwt = "refreshJwt",
handle = Handle("bob.bsky.social"),
did = Did("did:plc:123"),
)

val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine(createResponse)))

assertNull(api.authTokens.value)
api.createSession(createRequest)
assertEquals(BlueskyAuthPlugin.Tokens("accessJwt", "refreshJwt"), api.authTokens.value)
api.clearCredentials()
assertNull(api.authTokens.value)
}

@Test
fun testFailingNetworkCallDoesNotSave() = runTest {
val createRequest = CreateSessionRequest(
identifier = "bob.bsky.social",
password = "password",
)
val error = AtpErrorDescription(
error = "AuthFactorTokenRequired",
message = "Needs 2FA",
)

val api = AuthenticatedXrpcBlueskyApi(HttpClient(mockEngine(error)))

assertNull(api.authTokens.value)
api.createSession(createRequest)
assertNull(api.authTokens.value)
}
}
40 changes: 40 additions & 0 deletions bluesky/src/commonTest/kotlin/sh/christian/ozone/api/mockEngine.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package sh.christian.ozone.api

import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.request.HttpRequestData
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.utils.io.ByteReadChannel
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import sh.christian.ozone.BlueskyJson
import sh.christian.ozone.api.response.AtpErrorDescription

inline fun <reified T : @Serializable Any> mockEngine(
response: T,
statusCode: HttpStatusCode = HttpStatusCode.OK,
): MockEngine {
return mockEngine(statusCode = statusCode) { BlueskyJson.encodeToString(response) }
}

inline fun mockEngine(
error: AtpErrorDescription,
statusCode: HttpStatusCode = HttpStatusCode.BadRequest,
): MockEngine {
return mockEngine(response = error, statusCode = statusCode)
}

inline fun mockEngine(
statusCode: HttpStatusCode = HttpStatusCode.OK,
noinline responseProvider: (HttpRequestData) -> String,
): MockEngine {
return MockEngine { request ->
respond(
content = ByteReadChannel(responseProvider(request)),
status = statusCode,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
}
Loading

0 comments on commit ea145d3

Please sign in to comment.