test(02-01): add failing serialization test for MeResponse DTO
RED phase of TDD task 02-01-01. Locks the wire-format contract for GET /api/v1/me before the DTO exists: - camelCase JSON keys (id, sub, email, displayName) per D-27 - ignoreUnknownKeys forward compat for Phase 3 householdId per D-28 - MeResponse.toUser() one-to-one mapping Wires kotlinx.serialization into shared/build.gradle.kts (api scope so both client and server inherit the @Serializable runtime) and adds the kotlinx-serializationJson catalog alias. The shared module remains pure: only kotlin stdlib + kotlinx.serialization-json are pulled into commonMain (D-19 / INFRA-06 still holds). Test currently fails: MeResponse and User unresolved; GREEN follows.
This commit is contained in:
@@ -28,6 +28,9 @@ spotless = "8.4.0"
|
|||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||||
junit = { module = "junit:junit", version.ref = "junit" }
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
|
|
||||||
|
# kotlinx.serialization (shared DTOs — D-27)
|
||||||
|
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
||||||
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
|
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
|
||||||
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
|
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ plugins {
|
|||||||
// which requires the Android Gradle Plugin to already be on the project.
|
// which requires the Android Gradle Plugin to already be on the project.
|
||||||
alias(libs.plugins.androidLibrary)
|
alias(libs.plugins.androidLibrary)
|
||||||
id("recipe.kotlin.multiplatform")
|
id("recipe.kotlin.multiplatform")
|
||||||
|
alias(libs.plugins.kotlinSerialization)
|
||||||
id("recipe.quality")
|
id("recipe.quality")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,8 +16,12 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// Phase 1: intentionally empty. Domain models + DTOs land Phase 2+.
|
// Phase 2: DTOs land here (MeResponse/User per D-27). kotlinx.serialization
|
||||||
// D-19 / INFRA-06: No Ktor, Compose, SQLDelight, Koin, or Kermit here — EVER.
|
// is the only allowed runtime dependency in shared/commonMain — D-19 / INFRA-06
|
||||||
|
// forbids Ktor, Compose, SQLDelight, Koin, Kermit. `api(...)` so consumers
|
||||||
|
// (composeApp, server) inherit the @Serializable runtime without each
|
||||||
|
// re-declaring it.
|
||||||
|
api(libs.kotlinx.serializationJson)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package dev.ulfrx.recipe.shared.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire-format contract for [MeResponse] (D-27).
|
||||||
|
*
|
||||||
|
* Phase 2 pins the DTO shape used by both the Ktor server's `/api/v1/me` route and the
|
||||||
|
* KMP client. The fields, JSON keys, and forward-compatibility behavior asserted here are
|
||||||
|
* the load-bearing contract — downstream Phase 2 plans implement against it.
|
||||||
|
*/
|
||||||
|
class MeResponseSerializationTest {
|
||||||
|
private val strictJson = Json { encodeDefaults = true }
|
||||||
|
private val lenientJson =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round trips id sub email and displayName via camelCase wire keys`() {
|
||||||
|
val original =
|
||||||
|
MeResponse(
|
||||||
|
id = "11111111-2222-3333-4444-555555555555",
|
||||||
|
sub = "authentik|abc",
|
||||||
|
email = "anna@example.test",
|
||||||
|
displayName = "Anna",
|
||||||
|
)
|
||||||
|
|
||||||
|
val encoded = strictJson.encodeToString(MeResponse.serializer(), original)
|
||||||
|
|
||||||
|
// Wire keys must match the server contract exactly. Authentik's `sub` is opaque,
|
||||||
|
// so we never rename it on the wire.
|
||||||
|
assertEquals(
|
||||||
|
"{\"id\":\"11111111-2222-3333-4444-555555555555\"," +
|
||||||
|
"\"sub\":\"authentik|abc\"," +
|
||||||
|
"\"email\":\"anna@example.test\"," +
|
||||||
|
"\"displayName\":\"Anna\"}",
|
||||||
|
encoded,
|
||||||
|
)
|
||||||
|
|
||||||
|
val decoded = strictJson.decodeFromString(MeResponse.serializer(), encoded)
|
||||||
|
assertEquals(original, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decoder ignores future fields like householdId without failing`() {
|
||||||
|
// Phase 3 will extend the response with `householdId` (D-28). The Phase 2 client
|
||||||
|
// must keep parsing the response after that change, so the decoder must skip
|
||||||
|
// unknown keys.
|
||||||
|
val withFutureField =
|
||||||
|
"{\"id\":\"11111111-2222-3333-4444-555555555555\"," +
|
||||||
|
"\"sub\":\"authentik|abc\"," +
|
||||||
|
"\"email\":\"anna@example.test\"," +
|
||||||
|
"\"displayName\":\"Anna\"," +
|
||||||
|
"\"householdId\":\"99999999-0000-0000-0000-000000000000\"}"
|
||||||
|
|
||||||
|
val decoded = lenientJson.decodeFromString(MeResponse.serializer(), withFutureField)
|
||||||
|
|
||||||
|
assertEquals("11111111-2222-3333-4444-555555555555", decoded.id)
|
||||||
|
assertEquals("authentik|abc", decoded.sub)
|
||||||
|
assertEquals("anna@example.test", decoded.email)
|
||||||
|
assertEquals("Anna", decoded.displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toUser maps every field one-to-one without dropping data`() {
|
||||||
|
val response =
|
||||||
|
MeResponse(
|
||||||
|
id = "11111111-2222-3333-4444-555555555555",
|
||||||
|
sub = "authentik|abc",
|
||||||
|
email = "anna@example.test",
|
||||||
|
displayName = "Anna",
|
||||||
|
)
|
||||||
|
|
||||||
|
val user = response.toUser()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
User(
|
||||||
|
id = response.id,
|
||||||
|
sub = response.sub,
|
||||||
|
email = response.email,
|
||||||
|
displayName = response.displayName,
|
||||||
|
),
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user