fix(commons): tighter Base64Image contract + pin serializer wire format

Two follow-up cleanups from the audit.

Base64Image.parse: when the regex matched but the data capture group
was missing, the migrated version returned an empty ByteArray. The
original threw NPE (java.util.Base64.getDecoder().decode(null)). Both
behaviors are accidents — restore the intended contract: throw the
existing "Unable to convert base64 to image" Exception explicitly.

FeedDefinitionSerializerTest gains a serializesToExpectedWireFormat
test that pins the byte-exact JSON output for a representative
multi-field feed. The legacy-Jackson migration claimed byte-identity
but only round-trip and reverse-compat were covered. Any future change
to field ordering / null handling / number formatting now fails this
test loudly, protecting users who have saved feeds on disk and any
downstream consumer expecting the stable order.
This commit is contained in:
Claude
2026-05-25 20:05:00 +00:00
parent d1749c314f
commit 44aa262363
2 changed files with 43 additions and 7 deletions
@@ -30,12 +30,10 @@ object Base64Image {
@OptIn(ExperimentalEncodingApi::class)
fun parse(content: String): ByteArray {
val matcher = pattern.find(content)
if (matcher != null) {
val base64String = matcher.groups[2]?.value ?: ""
return Base64.decode(base64String)
}
throw Exception("Unable to convert base64 to image $content")
val match = pattern.find(content) ?: throw Exception("Unable to convert base64 to image $content")
val base64String =
match.groups[2]?.value
?: throw Exception("Unable to convert base64 to image $content")
return Base64.decode(base64String)
}
}
@@ -212,6 +212,44 @@ class FeedDefinitionSerializerTest {
assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" "))
}
@Test
fun serializesToExpectedWireFormat() {
// Pins the byte-exact JSON output so any future change to the
// serializer (field order, null handling, number formatting) is
// caught here. Users have feeds saved on disk in this exact format
// and may share JSON between app versions; downstream consumers
// depend on the field order being stable.
val feed =
FeedDefinition(
id = "test-1",
name = "Bitcoin",
emoji = "",
pinned = true,
pinOrder = 0,
source =
FeedSource.Filter(
hashtags = persistentListOf("bitcoin", "btc"),
authors = persistentListOf("abc123"),
relays = persistentListOf("wss://relay.damus.io"),
excludeAuthors = persistentListOf("spammer"),
excludeKeywords = persistentListOf("scam"),
kinds = persistentListOf(1, 6),
),
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 1000L,
)
val expected =
"""[{"id":"test-1","name":"Bitcoin","emoji":"₿","pinned":true,""" +
""""pinOrder":0,"refreshMode":"LIVE_STREAM","createdAt":1000,""" +
""""source":{"type":"filter","hashtags":["bitcoin","btc"],""" +
""""authors":["abc123"],"relays":["wss://relay.damus.io"],""" +
""""excludeAuthors":["spammer"],"excludeKeywords":["scam"],""" +
""""kinds":[1,6]}}]"""
assertEquals(expected, FeedDefinitionSerializer.serializeList(listOf(feed)))
}
@Test
fun parsesLegacyJacksonOutput() {
// Wire format previously emitted by ObjectMapper. Users have feed