From 6504b46e4020e10f76207a79c24eda0e1d54b132 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Tue, 28 Apr 2026 10:43:15 +0200 Subject: [PATCH] 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. --- gradle/libs.versions.toml | 3 + shared/build.gradle.kts | 9 +- .../shared/dto/MeResponseSerializationTest.kt | 90 +++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index df7df52..b8435f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,9 @@ spotless = "8.4.0" kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } 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-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 1b4836e..79290f1 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -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) } } } diff --git a/shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt b/shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt new file mode 100644 index 0000000..425cf2a --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt @@ -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, + ) + } +}