test(api/rest): add basic endpoint tests

This commit is contained in:
Cilly Leang 2026-02-16 15:51:03 +11:00
parent c7fb2817fc
commit 46218aa3c7
Signed by: cilly
GPG key ID: 6500251E087653C9
9 changed files with 253 additions and 8 deletions

View file

@ -2,8 +2,10 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.kotest)
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.ksp)
}
kotlin {
@ -30,12 +32,28 @@ kotlin {
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotest.assertions)
implementation(libs.kotest.framework)
implementation(libs.kotest.property)
implementation(libs.ktor.client.mock)
}
jvmMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
jvmTest.dependencies {
implementation(libs.kotest.runner.junit5)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
}
}
tasks.named<Test>("jvmTest") {
useJUnitPlatform()
}
//tasks.withType<Test>().configureEach {
// logger.lifecycle("UP-TO-DATE check for $name is disabled, forcing it to run.")
// outputs.upToDateWhen { false }
//}

View file

@ -3,6 +3,7 @@ package moe.lava.neon.api
import co.touchlab.kermit.Logger
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.cookies.HttpCookies
@ -52,6 +53,7 @@ class ApiClient internal constructor(
plugin(HttpSend).intercept { req ->
logger.d { "Intercepting ${req.url.buildString()}" }
val call = execute(req)
logger.d { "recv ${call.response.bodyAsText()}" }
if (call.response.status.value != 400) return@intercept call
logger.d { "Found 400 response: ${call.response.bodyAsText()}" }
val captchaRequest = runCatching { call.response.body<CaptchaRequest>() }

View file

@ -0,0 +1,70 @@
package moe.lava.neon.tests.api
import io.kotest.property.Arb
import io.kotest.property.arbitrary.long
import io.kotest.property.arbitrary.next
import io.kotest.property.arbitrary.single
import io.kotest.property.arbitrary.string
import io.kotest.property.arbitrary.stringPattern
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.MockRequestHandleScope
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.respondError
import io.ktor.client.request.HttpResponseData
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.TextContent
import io.ktor.http.headersOf
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import moe.lava.neon.tests.api.mock.AuthResponse
private val JsonHeader = headersOf(HttpHeaders.ContentType, "application/json")
val idArb = Arb.long(1e18.toLong(), 1e20.toLong())
// Good enough
val tokenArb = Arb.stringPattern("(mfa\\.[a-zA-Z0-9_-]{20,})|([a-zA-Z0-9_-]{23,28}\\.[a-zA-Z0-9_-]{6,7}\\.[a-zA-Z0-9_-]{38})")
class DiscordApiMock {
private val fingerprints = mutableListOf<String>()
private val logins = mutableMapOf<String, String>()
fun createLogin(email: String, password: String) {
logins[email] = password
}
val engine = MockEngine { req ->
if (!req.url.toString().startsWith("https://discord.com/api/v9")) {
return@MockEngine respondError(HttpStatusCode.NotFound)
}
val path = req.url.encodedPath.replaceFirst("/api/v9", "")
return@MockEngine when (path) {
"/experiments" -> {
val fp = Arb.string(18..20, "123456789").single()
fingerprints.add(fp)
respond(AuthResponse.Experiments(fp), headers = JsonHeader)
}
"/auth/login" -> {
val body = req.body as? TextContent
?: return@MockEngine badReq("No body")
val json = Json.parseToJsonElement(body.text).jsonObject
val login = json["login"]?.jsonPrimitive?.content
?: return@MockEngine badReq("No login")
val password = json["password"]?.jsonPrimitive?.content
?: return@MockEngine badReq("No password")
if (logins[login] != password) {
return@MockEngine badReq("Unknown credentials")
}
respond(AuthResponse.Login(idArb.next(), tokenArb.next()), headers = JsonHeader)
}
else -> respondError(HttpStatusCode.NotFound)
}
}
@Suppress("NOTHING_TO_INLINE")
inline fun MockRequestHandleScope.badReq(msg: String): HttpResponseData =
respondError(HttpStatusCode.BadRequest, content = "[Neon] $msg")
}

View file

@ -0,0 +1,84 @@
package moe.lava.neon.tests.api
import io.kotest.assertions.throwables.shouldNotThrowAny
import io.kotest.assertions.withClue
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.spec.style.funSpec
import io.kotest.core.spec.style.scopes.FunSpecContainerScope
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.collections.shouldBeOneOf
import io.kotest.matchers.collections.shouldContainAllInAnyOrder
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.ktor.client.statement.request
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import moe.lava.neon.api.ApiResponse
import kotlin.io.encoding.Base64
fun <T> endpointTestFactory(
endpoint: String,
testName: String?,
response: suspend () -> ApiResponse<T>,
also: suspend FunSpecContainerScope.(response: ApiResponse<T>) -> Unit,
) = funSpec {
context(testName ?: endpoint) {
val res = response()
val req = res.response.request
test("has correct base url") {
req.url.toString().startsWith("https://discord.com/api/v9/") shouldBe true
}
test("uses correct endpoint") {
req.url.encodedPath.replace("/api/v9/", "") shouldBe endpoint
}
test("has valid super props") {
val props = req.headers["X-Super-Properties"]
props.shouldNotBeNull()
val decoded = withClue("should be decodable") {
shouldNotThrowAny {
Base64.decode(props).decodeToString()
}
}
val parsed = withClue("should be parsable") {
shouldNotThrowAny {
Json.parseToJsonElement(decoded).jsonObject
}
}
withClue("has props") {
parsed.keys shouldContainAllInAnyOrder setOf(
"os",
"browser",
"browser_user_agent",
"browser_version",
"client_build_number",
"release_channel",
"system_locale",
)
}
val userAgent = withClue("has valid user agent") {
val agent = parsed["browser_user_agent"]?.jsonPrimitive
agent?.isString.shouldBeTrue()
agent.content
}
withClue("has matching user agent") {
userAgent shouldBeOneOf setOf(req.headers["User-Agent"], "")
}
}
test("has correct body") {
shouldNotThrowAny { res.body() }
}
also(res)
}
}
fun <T> FunSpec.withFactory(
testName: String? = null,
endpoint: String,
response: suspend () -> ApiResponse<T>,
also: suspend FunSpecContainerScope.(response: ApiResponse<T>) -> Unit = {},
) {
include(endpointTestFactory(endpoint, testName, response, also))
}

View file

@ -0,0 +1,54 @@
package moe.lava.neon.tests.api.endpoints
import io.kotest.assertions.throwables.shouldNotThrowAny
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.statement.request
import io.ktor.http.content.TextContent
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import moe.lava.neon.api.ApiClient
import moe.lava.neon.api.endpoints.getExperiments
import moe.lava.neon.api.endpoints.login
import moe.lava.neon.tests.api.DiscordApiMock
import moe.lava.neon.tests.api.withFactory
class AuthTest : FunSpec({
val mock = DiscordApiMock()
val client = ApiClient(mock.engine, false)
var fp: String? = null
withFactory(
testName = "get experiments",
endpoint = "experiments",
response = client::getExperiments
) { res ->
val body = res.body()
fp = body.fingerprint
}
val email = "hello@example.com"
val password = "supersecurepassword"
mock.createLogin(email, password)
withFactory(
testName = "login with real creds",
endpoint = "auth/login",
response = { client.login(email, password, fp!!) }
) { res ->
val req = res.response.request
val headers = req.headers
test("has correct fingerprint") {
headers["X-Fingerprint"] shouldBe fp
}
context("has correct body") {
val body = shouldNotThrowAny { Json.parseToJsonElement((req.content as TextContent).text).jsonObject }
test("has correct login") {
body["login"]?.jsonPrimitive?.content shouldBe email
}
test("has correct password") {
body["password"]?.jsonPrimitive?.content shouldBe password
}
}
}
})

View file

@ -0,0 +1,6 @@
package moe.lava.neon.tests.api.mock
object AuthResponse {
fun Experiments(fp: String) = """{"fingerprint":"$fp","assignments":[[3643362751,0,1,-1,0,4342,0,0,null,null,null],[1428438599,0,1,-1,2,3832,0,0,null,null,null],[1567199723,0,1,-1,1,1775,0,0,null,null,null],[4179344527,3,1,-1,0,3584,0,0,null,null,null],[1814483290,0,1,-1,0,6281,0,0,null,null,null],[4221006726,0,1,-1,0,4318,0,0,null,null,null],[1398673921,1,1,-1,0,4649,0,0,null,null,null],[1034661306,0,1,-1,0,1792,0,0,null,null,null],[3124003316,1,1,-1,0,1427,0,0,null,null,null],[2676348506,0,1,-1,0,4412,0,0,null,null,null],[4136574802,0,1,-1,1,4337,0,0,null,null,null],[4049571159,0,1,-1,0,504,0,0,null,null,null],[2539540256,0,1,-1,3,25,0,0,null,null,null],[1549543958,2,1,-1,0,6992,0,0,null,null,null],[1333727,0,1,-1,0,4978,0,0,null,null,null],[3029387945,1,1,-1,0,8670,0,0,null,null,null],[738080167,0,1,-1,1,9441,0,0,null,null,null],[3283745071,0,1,-1,1,9135,0,0,null,null,null],[373531156,0,3,-1,0,7954,0,0,null,null,null],[1617749743,0,1,-1,0,8281,0,0,null,null,null],[288968706,0,1,-1,3,7867,0,0,null,null,null],[2091202574,0,1,-1,0,8443,0,0,null,null,null],[4265918989,0,1,-1,0,2580,0,0,null,null,null],[1083932689,0,1,-1,0,6167,0,0,null,null,null],[1884426471,0,1,-1,0,5258,0,0,null,null,null],[2180379513,0,1,-1,4,5727,0,0,null,null,null],[759064140,0,1,-1,0,6050,0,0,null,null,null],[1680860120,0,1,-1,1,6633,0,0,null,null,null],[151550492,0,1,-1,2,102,0,1,null,null,null],[2054293512,0,0,-1,0,193,0,1,null,null,null],[2848826960,0,1,-1,0,7093,0,0,null,null,null],[3775594731,3,1,-1,0,1418,0,0,null,null,null],[878040044,1,1,-1,0,6738,0,1,null,null,null],[2990331215,0,1,-1,0,2786,0,0,null,null,null],[996399186,2,1,-1,0,5255,0,0,null,null,null],[3173338335,0,0,-1,0,178,0,1,null,null,null],[640084831,3,1,-1,0,5284,0,0,null,null,null],[1112953678,0,1,-1,0,3640,0,0,null,null,null],[4285324985,0,1,-1,0,8618,0,0,null,null,null],[4206392105,4,1,-1,0,5758,0,0,null,null,null],[1714347921,0,1,-1,0,2255,0,0,null,null,null],[3936291300,3,1,-1,0,3408,0,0,null,null,null],[2660711063,0,1,-1,0,8317,0,0,null,null,null],[1644303758,0,1,-1,0,2365,0,1,null,null,null],[114771571,0,1,-1,0,796,0,0,null,null,null],[437074334,5,1,-1,2,2482,0,1,null,null,null],[1978990512,3,1,-1,0,9101,0,0,null,null,null],[3378028029,3,2,-1,0,1997,0,1,null,null,null],[1046173986,0,1,-1,0,9264,0,0,null,null,null],[1757800499,1,1,-1,0,9654,0,0,null,null,null],[2849514387,0,1,-1,0,275,0,1,null,null,null],[2613104049,0,1,-1,0,8339,0,1,null,null,null],[2482010813,0,1,-1,0,4372,0,1,null,null,null],[1778984745,0,1,-1,0,1685,0,1,null,null,null],[2870923171,0,1,-1,0,101,0,0,null,null,null],[1598219105,1,2,-1,0,3159,0,1,null,null,null],[641666131,1,1,-1,0,2501,0,0,null,null,null]]}"""
fun Login(userId: Long, token: String) = """{"user_id":"$userId","token":"$token","user_settings":{"locale":"en-US","theme":"dark"}}"""
}

View file

@ -24,7 +24,14 @@ internal data class PlatformProps(
internal expect val platformSuperProps: PlatformProps
object ApiConstants {
val superProps = Base64.encode(Json.encodeToString(SuperProperties()).encodeToByteArray())
@OptIn(ExperimentalSerializationApi::class)
val json = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true
encodeDefaults = true
}
val superProps = Base64.encode(json.encodeToString(SuperProperties()).encodeToByteArray())
val baseHeaders = mapOf(
"X-Debug-Options" to "bugReporterEnabled",
"X-Discord-Locale" to "en-US",
@ -34,13 +41,6 @@ object ApiConstants {
const val userAgent = "Discord-Android/311020;RNA"
const val gatewayUserAgent = "okhttp/4.12.0"
@OptIn(ExperimentalSerializationApi::class)
val json = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true
encodeDefaults = true
}
// TODO: Desktop uses separate properties
@Suppress("PropertyName")
@Serializable