fix: register CLINK DTO serializers in KotlinSerializationMapper for native targets

All CLINK tests failed on iosSimulatorArm64 with IllegalArgumentException
because OptimizedJsonMapper on native dispatches through
KotlinSerializationMapper, whose fromJsonTo/toJson type lists did not
include the CLINK payload DTOs (Jackson handles them reflectively on
JVM/Android, which is why only iOS failed).

Adds hand-written kotlinx serializers for OfferRequest/OfferResponse/
OfferReceipt, DebitRequest/DebitResponse, and ManageRequest/ManageResponse,
mirroring Jackson behavior: ManageResponse.details coerces a lone object
into a one-element list (ACCEPT_SINGLE_VALUE_AS_ARRAY) and
OfferRequest.payer_data round-trips as a free-form JSON object.

Covered by a JVM test driving KotlinSerializationMapper directly and
cross-checking against Jackson, since the native path shares this code.

https://claude.ai/code/session_01SevV4fUCumKZ1UscSz85vS
This commit is contained in:
Claude
2026-06-11 21:33:49 +00:00
parent e8ed9379b8
commit 9999d92bca
3 changed files with 648 additions and 0 deletions
@@ -0,0 +1,399 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.clink.kotlinSerialization
import com.vitorpamplona.quartz.experimental.clink.common.GfyDelta
import com.vitorpamplona.quartz.experimental.clink.common.SatRange
import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency
import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest
import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse
import com.vitorpamplona.quartz.experimental.clink.manage.ManageOffer
import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest
import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse
import com.vitorpamplona.quartz.experimental.clink.manage.OfferData
import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields
import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt
import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.toAnyMap
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
/**
* Hand-written kotlinx serializers for the CLINK decrypted payload DTOs
* (`OfferRequest`/`OfferResponse`/`OfferReceipt`, `DebitRequest`/`DebitResponse`,
* `ManageRequest`/`ManageResponse`). They mirror what Jackson does reflectively on
* JVM/Android — including coercing a lone `details` object into a one-element list
* (Jackson's `ACCEPT_SINGLE_VALUE_AS_ARRAY`) — so native targets parse the same wire shapes.
*/
private fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> JsonNull
is JsonElement -> value
is String -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is Number -> JsonPrimitive(value)
is Map<*, *> -> buildJsonObject { value.forEach { (k, v) -> put(k.toString(), anyToJsonElement(v)) } }
is Iterable<*> -> buildJsonArray { value.forEach { add(anyToJsonElement(it)) } }
else -> JsonPrimitive(value.toString())
}
private fun JsonObject.stringOrNull(key: String): String? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.content }
private fun JsonObject.longOrNull(key: String): Long? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.longOrNull }
private fun JsonObject.intOrNull(key: String): Int? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.intOrNull }
private fun JsonObject.objectOrNull(key: String): JsonObject? = get(key) as? JsonObject
private fun JsonObject.stringListOrNull(key: String): List<String>? = (get(key) as? JsonArray)?.map { it.jsonPrimitive.content }
private fun serializeSatRange(range: SatRange): JsonObject =
buildJsonObject {
range.min?.let { put("min", it) }
range.max?.let { put("max", it) }
}
private fun parseSatRange(obj: JsonObject): SatRange =
SatRange(
min = obj.longOrNull("min"),
max = obj.longOrNull("max"),
)
private fun serializeGfyDelta(delta: GfyDelta): JsonObject =
buildJsonObject {
delta.max_delta_ms?.let { put("max_delta_ms", it) }
delta.actual_delta_ms?.let { put("actual_delta_ms", it) }
}
private fun parseGfyDelta(obj: JsonObject): GfyDelta =
GfyDelta(
max_delta_ms = obj.longOrNull("max_delta_ms"),
actual_delta_ms = obj.longOrNull("actual_delta_ms"),
)
object OfferRequestKSerializer : KSerializer<OfferRequest> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkOfferRequest")
override fun serialize(
encoder: Encoder,
value: OfferRequest,
) {
val jsonObject =
buildJsonObject {
value.offer?.let { put("offer", it) }
value.amount_sats?.let { put("amount_sats", it) }
value.payer_data?.let { put("payer_data", anyToJsonElement(it)) }
value.zap?.let { put("zap", it) }
value.expires_in_seconds?.let { put("expires_in_seconds", it) }
value.description?.let { put("description", it) }
}
(encoder as JsonEncoder).encodeJsonElement(jsonObject)
}
override fun deserialize(decoder: Decoder): OfferRequest {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
return OfferRequest(
offer = obj.stringOrNull("offer"),
amount_sats = obj.longOrNull("amount_sats"),
payer_data = obj.objectOrNull("payer_data")?.toAnyMap(),
zap = obj.stringOrNull("zap"),
expires_in_seconds = obj.longOrNull("expires_in_seconds"),
description = obj.stringOrNull("description"),
)
}
}
object OfferResponseKSerializer : KSerializer<OfferResponse> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkOfferResponse")
override fun serialize(
encoder: Encoder,
value: OfferResponse,
) {
val jsonObject =
buildJsonObject {
value.bolt11?.let { put("bolt11", it) }
value.error?.let { put("error", it) }
value.code?.let { put("code", it) }
value.range?.let { put("range", serializeSatRange(it)) }
value.latest?.let { put("latest", it) }
}
(encoder as JsonEncoder).encodeJsonElement(jsonObject)
}
override fun deserialize(decoder: Decoder): OfferResponse {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
return OfferResponse(
bolt11 = obj.stringOrNull("bolt11"),
error = obj.stringOrNull("error"),
code = obj.intOrNull("code"),
range = obj.objectOrNull("range")?.let { parseSatRange(it) },
latest = obj.stringOrNull("latest"),
)
}
}
object OfferReceiptKSerializer : KSerializer<OfferReceipt> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkOfferReceipt")
override fun serialize(
encoder: Encoder,
value: OfferReceipt,
) {
val jsonObject =
buildJsonObject {
value.res?.let { put("res", it) }
value.preimage?.let { put("preimage", it) }
}
(encoder as JsonEncoder).encodeJsonElement(jsonObject)
}
override fun deserialize(decoder: Decoder): OfferReceipt {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
return OfferReceipt(
res = obj.stringOrNull("res"),
preimage = obj.stringOrNull("preimage"),
)
}
}
object DebitRequestKSerializer : KSerializer<DebitRequest> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkDebitRequest")
override fun serialize(
encoder: Encoder,
value: DebitRequest,
) {
val jsonObject =
buildJsonObject {
value.pointer?.let { put("pointer", it) }
value.amount_sats?.let { put("amount_sats", it) }
value.bolt11?.let { put("bolt11", it) }
value.description?.let { put("description", it) }
value.k1?.let { put("k1", it) }
value.frequency?.let { put("frequency", serializeDebitFrequency(it)) }
}
(encoder as JsonEncoder).encodeJsonElement(jsonObject)
}
override fun deserialize(decoder: Decoder): DebitRequest {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
return DebitRequest(
pointer = obj.stringOrNull("pointer"),
amount_sats = obj.longOrNull("amount_sats"),
bolt11 = obj.stringOrNull("bolt11"),
description = obj.stringOrNull("description"),
k1 = obj.stringOrNull("k1"),
frequency = obj.objectOrNull("frequency")?.let { parseDebitFrequency(it) },
)
}
private fun serializeDebitFrequency(frequency: DebitFrequency): JsonObject =
buildJsonObject {
frequency.number?.let { put("number", it) }
frequency.unit?.let { put("unit", it) }
}
private fun parseDebitFrequency(obj: JsonObject): DebitFrequency =
DebitFrequency(
number = obj.intOrNull("number"),
unit = obj.stringOrNull("unit"),
)
}
object DebitResponseKSerializer : KSerializer<DebitResponse> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkDebitResponse")
override fun serialize(
encoder: Encoder,
value: DebitResponse,
) {
val jsonObject =
buildJsonObject {
value.res?.let { put("res", it) }
value.preimage?.let { put("preimage", it) }
value.code?.let { put("code", it) }
value.error?.let { put("error", it) }
value.range?.let { put("range", serializeSatRange(it)) }
value.retry_after?.let { put("retry_after", it) }
value.delta?.let { put("delta", serializeGfyDelta(it)) }
}
(encoder as JsonEncoder).encodeJsonElement(jsonObject)
}
override fun deserialize(decoder: Decoder): DebitResponse {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
return DebitResponse(
res = obj.stringOrNull("res"),
preimage = obj.stringOrNull("preimage"),
code = obj.intOrNull("code"),
error = obj.stringOrNull("error"),
range = obj.objectOrNull("range")?.let { parseSatRange(it) },
retry_after = obj.longOrNull("retry_after"),
delta = obj.objectOrNull("delta")?.let { parseGfyDelta(it) },
)
}
}
object ManageRequestKSerializer : KSerializer<ManageRequest> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkManageRequest")
override fun serialize(
encoder: Encoder,
value: ManageRequest,
) {
val jsonObject =
buildJsonObject {
value.resource?.let { put("resource", it) }
value.action?.let { put("action", it) }
value.pointer?.let { put("pointer", it) }
value.offer?.let { put("offer", serializeManageOffer(it)) }
}
(encoder as JsonEncoder).encodeJsonElement(jsonObject)
}
override fun deserialize(decoder: Decoder): ManageRequest {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
return ManageRequest(
resource = obj.stringOrNull("resource"),
action = obj.stringOrNull("action"),
pointer = obj.stringOrNull("pointer"),
offer = obj.objectOrNull("offer")?.let { parseManageOffer(it) },
)
}
private fun serializeManageOffer(offer: ManageOffer): JsonObject =
buildJsonObject {
offer.id?.let { put("id", it) }
offer.fields?.let { put("fields", serializeOfferFields(it)) }
}
private fun parseManageOffer(obj: JsonObject): ManageOffer =
ManageOffer(
id = obj.stringOrNull("id"),
fields = obj.objectOrNull("fields")?.let { parseOfferFields(it) },
)
private fun serializeOfferFields(fields: OfferFields): JsonObject =
buildJsonObject {
fields.label?.let { put("label", it) }
fields.price_sats?.let { put("price_sats", it) }
fields.callback_url?.let { put("callback_url", it) }
fields.payer_data?.let { names -> put("payer_data", buildJsonArray { names.forEach { add(it) } }) }
}
private fun parseOfferFields(obj: JsonObject): OfferFields =
OfferFields(
label = obj.stringOrNull("label"),
price_sats = obj.longOrNull("price_sats"),
callback_url = obj.stringOrNull("callback_url"),
payer_data = obj.stringListOrNull("payer_data"),
)
}
object ManageResponseKSerializer : KSerializer<ManageResponse> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkManageResponse")
override fun serialize(
encoder: Encoder,
value: ManageResponse,
) {
val jsonObject =
buildJsonObject {
value.res?.let { put("res", it) }
value.resource?.let { put("resource", it) }
value.details?.let { details ->
put("details", buildJsonArray { details.forEach { add(serializeOfferData(it)) } })
}
value.code?.let { put("code", it) }
value.error?.let { put("error", it) }
value.field?.let { put("field", it) }
value.range?.let { put("range", serializeSatRange(it)) }
value.retry_after?.let { put("retry_after", it) }
value.delta?.let { put("delta", serializeGfyDelta(it)) }
}
(encoder as JsonEncoder).encodeJsonElement(jsonObject)
}
override fun deserialize(decoder: Decoder): ManageResponse {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
return ManageResponse(
res = obj.stringOrNull("res"),
resource = obj.stringOrNull("resource"),
details = parseDetails(obj["details"]),
code = obj.intOrNull("code"),
error = obj.stringOrNull("error"),
field = obj.stringOrNull("field"),
range = obj.objectOrNull("range")?.let { parseSatRange(it) },
retry_after = obj.longOrNull("retry_after"),
delta = obj.objectOrNull("delta")?.let { parseGfyDelta(it) },
)
}
/** The spec types `details` as `OfferData | OfferData[]`: coerce a lone object into a one-element list. */
private fun parseDetails(element: JsonElement?): List<OfferData>? =
when (element) {
null, is JsonNull -> null
is JsonArray -> element.mapNotNull { (it as? JsonObject)?.let { obj -> parseOfferData(obj) } }
is JsonObject -> listOf(parseOfferData(element))
else -> null
}
private fun serializeOfferData(data: OfferData): JsonObject =
buildJsonObject {
data.id?.let { put("id", it) }
data.noffer?.let { put("noffer", it) }
data.label?.let { put("label", it) }
data.price_sats?.let { put("price_sats", it) }
data.callback_url?.let { put("callback_url", it) }
data.payer_data?.let { names -> put("payer_data", buildJsonArray { names.forEach { add(it) } }) }
}
private fun parseOfferData(obj: JsonObject): OfferData =
OfferData(
id = obj.stringOrNull("id"),
noffer = obj.stringOrNull("noffer"),
label = obj.stringOrNull("label"),
price_sats = obj.longOrNull("price_sats"),
callback_url = obj.stringOrNull("callback_url"),
payer_data = obj.stringListOrNull("payer_data"),
)
}
@@ -20,6 +20,20 @@
*/
package com.vitorpamplona.quartz.nip01Core.kotlinSerialization
import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest
import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse
import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.DebitRequestKSerializer
import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.DebitResponseKSerializer
import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.ManageRequestKSerializer
import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.ManageResponseKSerializer
import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.OfferReceiptKSerializer
import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.OfferRequestKSerializer
import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.OfferResponseKSerializer
import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest
import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse
import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt
import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
import com.vitorpamplona.quartz.nip01Core.core.TagArray
@@ -121,6 +135,34 @@ class KotlinSerializationMapper {
json.encodeToString(Nip47NotificationKSerializer, value)
}
is OfferRequest -> {
json.encodeToString(OfferRequestKSerializer, value)
}
is OfferResponse -> {
json.encodeToString(OfferResponseKSerializer, value)
}
is OfferReceipt -> {
json.encodeToString(OfferReceiptKSerializer, value)
}
is DebitRequest -> {
json.encodeToString(DebitRequestKSerializer, value)
}
is DebitResponse -> {
json.encodeToString(DebitResponseKSerializer, value)
}
is ManageRequest -> {
json.encodeToString(ManageRequestKSerializer, value)
}
is ManageResponse -> {
json.encodeToString(ManageResponseKSerializer, value)
}
else -> {
throw IllegalArgumentException("Unsupported type: ${value::class}")
}
@@ -141,6 +183,13 @@ class KotlinSerializationMapper {
Response::class -> json.decodeFromString(Nip47ResponseKSerializer, jsonStr)
Request::class -> json.decodeFromString(Nip47RequestKSerializer, jsonStr)
Notification::class -> json.decodeFromString(Nip47NotificationKSerializer, jsonStr)
OfferRequest::class -> json.decodeFromString(OfferRequestKSerializer, jsonStr)
OfferResponse::class -> json.decodeFromString(OfferResponseKSerializer, jsonStr)
OfferReceipt::class -> json.decodeFromString(OfferReceiptKSerializer, jsonStr)
DebitRequest::class -> json.decodeFromString(DebitRequestKSerializer, jsonStr)
DebitResponse::class -> json.decodeFromString(DebitResponseKSerializer, jsonStr)
ManageRequest::class -> json.decodeFromString(ManageRequestKSerializer, jsonStr)
ManageResponse::class -> json.decodeFromString(ManageResponseKSerializer, jsonStr)
else -> throw IllegalArgumentException("Unsupported type: ${T::class}")
}
@Suppress("UNCHECKED_CAST")
@@ -0,0 +1,200 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.clink
import com.vitorpamplona.quartz.experimental.clink.common.GfyDelta
import com.vitorpamplona.quartz.experimental.clink.common.SatRange
import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency
import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest
import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse
import com.vitorpamplona.quartz.experimental.clink.manage.ManageOffer
import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest
import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse
import com.vitorpamplona.quartz.experimental.clink.manage.OfferData
import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields
import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt
import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Exercises the kotlinx-serialization path for the CLINK DTOs — the code path that
* native targets (iOS) use via `OptimizedJsonMapper` — and cross-checks it against
* Jackson (the JVM/Android path) so both backends agree on the wire shapes.
*/
class ClinkKotlinSerializationTest {
@Test
fun offerRequestRoundTripAndCrossParse() {
val request =
OfferRequest(
offer = "coffee",
amount_sats = 21000,
payer_data = mapOf("email" to "a@b.c", "count" to 2L),
zap = "{...}",
expires_in_seconds = 3600,
description = "A coffee",
)
val kotlinJson = KotlinSerializationMapper.toJson(request)
val parsed = KotlinSerializationMapper.fromJsonTo<OfferRequest>(kotlinJson)
assertEquals("coffee", parsed.offer)
assertEquals(21000L, parsed.amount_sats)
assertEquals("a@b.c", parsed.payer_data?.get("email"))
// payer_data is an opaque pass-through map; numeric boxing differs between
// backends (Jackson: Int, kotlinx via toAnyValue(): Double), so compare values.
assertEquals(2L, (parsed.payer_data?.get("count") as Number).toLong())
assertEquals("{...}", parsed.zap)
assertEquals(3600L, parsed.expires_in_seconds)
assertEquals("A coffee", parsed.description)
// Jackson-serialized payload parses with kotlinx and vice versa.
val fromJackson = KotlinSerializationMapper.fromJsonTo<OfferRequest>(JacksonMapper.toJson(request))
assertEquals("coffee", fromJackson.offer)
val jacksonParsed = JacksonMapper.fromJsonTo<OfferRequest>(kotlinJson)
assertEquals(21000L, jacksonParsed.amount_sats)
}
@Test
fun offerResponseParsesRangeAndLatest() {
val parsed =
KotlinSerializationMapper.fromJsonTo<OfferResponse>(
"""{"error":"Invalid Amount","code":5,"range":{"min":1000,"max":50000},"latest":"noffer1qqsmoved"}""",
)
assertEquals(5, parsed.code)
assertEquals(1000L, parsed.range?.min)
assertEquals(50000L, parsed.range?.max)
assertEquals("noffer1qqsmoved", parsed.latest)
val success = KotlinSerializationMapper.fromJsonTo<OfferResponse>("""{"bolt11":"lnbc1..."}""")
assertTrue(success.isSuccess())
assertEquals(
"lnbc1...",
KotlinSerializationMapper.fromJsonTo<OfferResponse>(KotlinSerializationMapper.toJson(success)).bolt11,
)
}
@Test
fun offerReceiptRoundTrip() {
val receipt = OfferReceipt(res = OfferReceipt.OK, preimage = "ab".repeat(32))
val parsed = KotlinSerializationMapper.fromJsonTo<OfferReceipt>(KotlinSerializationMapper.toJson(receipt))
assertTrue(parsed.isOk())
assertEquals("ab".repeat(32), parsed.preimage)
}
@Test
fun debitRequestRoundTripWithFrequency() {
val request =
DebitRequest(
pointer = "ndebit1...",
amount_sats = 1000,
description = "budget",
k1 = "4caa9ee5",
frequency = DebitFrequency(1, DebitFrequency.UNIT_MONTH),
)
val parsed = KotlinSerializationMapper.fromJsonTo<DebitRequest>(KotlinSerializationMapper.toJson(request))
assertEquals("ndebit1...", parsed.pointer)
assertEquals(1000L, parsed.amount_sats)
assertEquals("4caa9ee5", parsed.k1)
assertEquals(1, parsed.frequency?.number)
assertEquals(DebitFrequency.UNIT_MONTH, parsed.frequency?.unit)
}
@Test
fun debitResponseParsesGfyExtras() {
val response =
DebitResponse(
res = DebitResponse.GFY,
code = 3,
error = "Expired Request",
range = SatRange(10, 10000000),
retry_after = 1717000000,
delta = GfyDelta(30000, 45000),
)
val parsed = KotlinSerializationMapper.fromJsonTo<DebitResponse>(KotlinSerializationMapper.toJson(response))
assertEquals(3, parsed.code)
assertEquals(10L, parsed.range?.min)
assertEquals(1717000000L, parsed.retry_after)
assertEquals(30000L, parsed.delta?.max_delta_ms)
assertEquals(45000L, parsed.delta?.actual_delta_ms)
}
@Test
fun manageRequestRoundTripsNestedOfferFields() {
val request =
ManageRequest(
resource = ManageRequest.RESOURCE_OFFER,
action = ManageRequest.ACTION_UPDATE,
offer =
ManageOffer(
id = "offer-1",
fields = OfferFields("Coffee", 1500L, "https://x/cb", listOf("email", "name")),
),
)
val json = KotlinSerializationMapper.toJson(request)
val parsed = KotlinSerializationMapper.fromJsonTo<ManageRequest>(json)
assertEquals("offer-1", parsed.offer?.id)
assertEquals("Coffee", parsed.offer?.fields?.label)
assertEquals(1500L, parsed.offer?.fields?.price_sats)
assertEquals(listOf("email", "name"), parsed.offer?.fields?.payer_data)
// Jackson reads the kotlinx output identically.
val jacksonParsed = JacksonMapper.fromJsonTo<ManageRequest>(json)
assertEquals("Coffee", jacksonParsed.offer?.fields?.label)
}
@Test
fun manageResponseCoercesSingleDetailsObjectToList() {
val single =
KotlinSerializationMapper.fromJsonTo<ManageResponse>(
"""{"res":"ok","resource":"offer","details":{"id":"o1","noffer":"noffer1...","label":"Coffee","price_sats":1500}}""",
)
assertEquals(1, single.details?.size)
assertEquals("o1", single.details?.first()?.id)
val array =
KotlinSerializationMapper.fromJsonTo<ManageResponse>(
"""{"res":"ok","resource":"offer","details":[{"id":"o1"},{"id":"o2"}]}""",
)
assertEquals(listOf("o1", "o2"), array.details?.map { it.id })
}
@Test
fun manageResponseRoundTripWithDetails() {
val response =
ManageResponse(
res = ManageResponse.OK,
resource = "offer",
details = listOf(OfferData(id = "o1", noffer = "noffer1...", label = "Coffee", price_sats = 1500)),
)
val json = KotlinSerializationMapper.toJson(response)
val parsed = KotlinSerializationMapper.fromJsonTo<ManageResponse>(json)
assertTrue(parsed.isOk())
assertEquals("o1", parsed.details?.first()?.id)
assertEquals(1500L, parsed.details?.first()?.price_sats)
val jacksonParsed = JacksonMapper.fromJsonTo<ManageResponse>(json)
assertEquals("noffer1...", jacksonParsed.details?.first()?.noffer)
}
}