fix(shard): decode in-game serials as hex strings, not numbers
All checks were successful
PR Checks / android-build (pull_request) Successful in 5m44s
All checks were successful
PR Checks / android-build (pull_request) Successful in 5m44s
The public shard board DTOs typed in-game serials (and actor webId) as
Long, but the wire protocol (docs/link/INTEGRATION.md §1) sends them as
opaque hex strings ("0x1A2B"). The website returns board payloads
verbatim, so a guild leader / champ / governor carrying a hex serial
threw JsonDecodingException out of the Retrofit converter and crashed the
app on the Guilds/Champs/Governors boards. The API is the source of
truth, so the DTOs are corrected to match it.
- ActorDto.serial/webId, ChampDto.serial, HouseDto.serial,
OnlineStaffDto.serial: Long -> String
- champ.remove / house.decay live frames now read serial via stringField;
longField returned null on a hex serial, silently dropping every board
removal and live IDOC update
- safeApiCall now catches SerializationException -> ErrorKind.SERVER, so
any future contract drift degrades to a retry-able error instead of a
crash (defense in depth)
- DTO + result tests updated to the real hex-string wire shapes
AI-assisted: authored with Claude Code (Opus 4.8).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
This commit is contained in:
@@ -35,6 +35,20 @@ class ApiResultTest {
|
||||
assertTrue(result is ApiResult.NetworkError)
|
||||
}
|
||||
|
||||
/**
|
||||
* A body the app can't decode (a field whose type doesn't match its DTO) throws a
|
||||
* [SerializationException] out of the Retrofit converter. It must degrade to a
|
||||
* server-side error the UI renders, not escape and crash the app — the guild-board
|
||||
* crash this fixes. `502` folds to [ui.ErrorKind.SERVER] via `toUiState`.
|
||||
*/
|
||||
@Test fun serializationExceptionBecomesServerError() = runTest {
|
||||
val result = safeApiCall {
|
||||
throw kotlinx.serialization.SerializationException("Unexpected symbol 'm' at path: \$[0].members")
|
||||
}
|
||||
assertTrue(result is ApiResult.HttpError)
|
||||
assertEquals(502, (result as ApiResult.HttpError).status)
|
||||
}
|
||||
|
||||
@Test fun cancellationIsRethrown() = runTest {
|
||||
assertThrows(CancellationException::class.java) {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
|
||||
@@ -43,25 +43,31 @@ class ShardDtoTest {
|
||||
}
|
||||
|
||||
@Test fun champUpdateFrameDecodesWithKindAndExtras() {
|
||||
// A live champ.update frame: has `kind`, `serial`, and category extras. The
|
||||
// `kind` field is ignored (not on the DTO) and the extras decode.
|
||||
// A live champ.update frame: has `kind`, a hex-string `serial` (INTEGRATION.md
|
||||
// §1 — serials are opaque hex keys, never numbers), and category extras. The
|
||||
// `kind`/`rank`/`autoRestart` fields are ignored (not on the DTO); extras decode.
|
||||
val dto = json.decodeFromString<ChampDto>(
|
||||
"""{"kind":"champ.update","serial":12345,"category":"champion","name":"Barracoon",
|
||||
"status":"active","active":true,"level":10,"maxKills":250,"kills":120,
|
||||
"bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""",
|
||||
"""{"kind":"champ.update","serial":"0x40012345","category":"champion","name":"Barracoon",
|
||||
"status":"active","active":true,"level":10,"rank":3,"maxKills":250,"kills":120,
|
||||
"autoRestart":true,"bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""",
|
||||
)
|
||||
assertEquals(12345L, dto.serial)
|
||||
assertEquals("0x40012345", dto.serial)
|
||||
assertEquals("champion", dto.category)
|
||||
assertEquals(120, dto.kills)
|
||||
assertTrue(dto.active)
|
||||
}
|
||||
|
||||
@Test fun guildFrameDecodesLeaderActor() {
|
||||
// The leader actor carries a hex-string serial and a string webId (the linked
|
||||
// site-user id) — the exact wire shape from INTEGRATION.md §7.
|
||||
val dto = json.decodeFromString<GuildDto>(
|
||||
"""{"kind":"guild.update","id":7,"name":"Knights","abbr":"KNT","members":12,
|
||||
"online":3,"alliance":"Light","leader":{"serial":1,"name":"Arthur","acct":"art"}}""",
|
||||
"online":3,"alliance":"Light",
|
||||
"leader":{"serial":"0x1A2B","name":"Arthur","acct":"art","webId":"9931","player":true}}""",
|
||||
)
|
||||
assertEquals(7L, dto.id)
|
||||
assertEquals("0x1A2B", dto.leader?.serial)
|
||||
assertEquals("9931", dto.leader?.webId)
|
||||
assertEquals("Arthur", dto.leader?.label)
|
||||
assertEquals(12, dto.members)
|
||||
}
|
||||
@@ -86,13 +92,21 @@ class ShardDtoTest {
|
||||
|
||||
@Test fun houseDecodesPublicIdocShape() {
|
||||
val dto = json.decodeFromString<HouseDto>(
|
||||
"""{"serial":999,"name":"Tower","region":"Britain","map":"Felucca",
|
||||
"""{"serial":"0x40001234","name":"Tower","region":"Britain","map":"Felucca",
|
||||
"x":1,"y":2,"z":3,"isIdoc":true}""",
|
||||
)
|
||||
assertEquals(999L, dto.serial)
|
||||
assertEquals("0x40001234", dto.serial)
|
||||
assertTrue(dto.isIdoc)
|
||||
}
|
||||
|
||||
@Test fun onlineStaffDecodesHexSerial() {
|
||||
val dto = json.decodeFromString<OnlineStaffDto>(
|
||||
"""{"serial":"0x24C","name":"Darrow"}""",
|
||||
)
|
||||
assertEquals("0x24C", dto.serial)
|
||||
assertEquals("Darrow", dto.name)
|
||||
}
|
||||
|
||||
@Test fun actorLabelFallsBackToAcctThenSomeone() {
|
||||
assertEquals("bob", ActorDto(acct = "bob").label)
|
||||
assertEquals("Someone", ActorDto().label)
|
||||
|
||||
Reference in New Issue
Block a user