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:
2026-04-28 10:43:15 +02:00
parent 1246e12012
commit 6504b46e40
3 changed files with 100 additions and 2 deletions

View File

@@ -3,6 +3,7 @@ plugins {
// which requires the Android Gradle Plugin to already be on the project.
alias(libs.plugins.androidLibrary)
id("recipe.kotlin.multiplatform")
alias(libs.plugins.kotlinSerialization)
id("recipe.quality")
}
@@ -15,8 +16,12 @@ kotlin {
sourceSets {
commonMain.dependencies {
// Phase 1: intentionally empty. Domain models + DTOs land Phase 2+.
// D-19 / INFRA-06: No Ktor, Compose, SQLDelight, Koin, or Kermit here — EVER.
// Phase 2: DTOs land here (MeResponse/User per D-27). kotlinx.serialization
// 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)
}
}
}

View File

@@ -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,
)
}
}