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:
@@ -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