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