fix: continue card-detail fallbacks on 2xx incompatibility

This commit is contained in:
2026-03-16 20:29:12 -04:00
parent 70f1558ea3
commit eee2f9cb17
2 changed files with 62 additions and 36 deletions

View File

@@ -757,7 +757,8 @@ class HttpKanbnApiClient : KanbnApiClient {
when (evaluateCommentResponse(response.body)) { when (evaluateCommentResponse(response.body)) {
CommentResponseClassification.Success -> return@withContext BoardsApiResult.Success(Unit) CommentResponseClassification.Success -> return@withContext BoardsApiResult.Success(Unit)
CommentResponseClassification.LogicalFailure -> { CommentResponseClassification.LogicalFailure -> {
return@withContext BoardsApiResult.Failure(extractLogicalFailureMessage(response.body)) lastFailure = extractLogicalFailureMessage(response.body)
continue
} }
CommentResponseClassification.ParseIncompatible -> continue CommentResponseClassification.ParseIncompatible -> continue
} }
@@ -1175,30 +1176,30 @@ class HttpKanbnApiClient : KanbnApiClient {
} }
val parsed = parseJsonValue(body) ?: return null val parsed = parseJsonValue(body) ?: return null
val items = when (parsed) { val items: List<Map<*, *>> = when (parsed) {
is List<*> -> parsed.mapNotNull { it as? Map<*, *> } is List<*> -> {
is Map<*, *> -> extractActivityArray(parsed) if (parsed.isEmpty()) {
else -> return null return emptyList()
} }
if (parsed.any { it !is Map<*, *> }) {
return null
}
@Suppress("UNCHECKED_CAST")
parsed as List<Map<*, *>>
}
if (items.isEmpty()) { is Map<*, *> -> {
val trimmed = body.trim() val extracted = extractActivityContainer(parsed) ?: return null
if (trimmed == "[]") { if (extracted.any { it !is Map<*, *> }) {
return emptyList() return null
}
if (extracted.isEmpty()) {
return emptyList()
}
extracted.mapNotNull { it as? Map<*, *> }
} }
val asMap = parsed as? Map<*, *> ?: return emptyList()
val hadContainer = sequenceOf( else -> return null
"activities",
"actions",
"cardActivities",
"card_activities",
"items",
"data",
).any { key -> asMap.containsKey(key) || ((asMap["data"] as? Map<*, *>)?.containsKey(key) == true) }
if (hadContainer) {
return null
}
return emptyList()
} }
return items.mapNotNull { raw -> return items.mapNotNull { raw ->
@@ -1218,13 +1219,24 @@ class HttpKanbnApiClient : KanbnApiClient {
} }
} }
private fun extractActivityArray(source: Map<*, *>): List<Map<*, *>> { private fun extractActivityContainer(source: Map<*, *>): List<*>? {
val topLevel = extractObjectArray(source, "activities", "actions", "cardActivities", "card_activities", "items", "data") val keys = listOf("activities", "actions", "cardActivities", "card_activities", "items", "data")
if (topLevel.isNotEmpty()) { keys.forEach { key ->
return topLevel if (source.containsKey(key)) {
val value = source[key]
return value as? List<*>
}
} }
val data = source["data"] as? Map<*, *> ?: return emptyList()
return extractObjectArray(data, "activities", "actions", "cardActivities", "card_activities", "items", "data") val data = source["data"] as? Map<*, *> ?: return null
keys.forEach { key ->
if (data.containsKey(key)) {
val value = data[key]
return value as? List<*>
}
}
return null
} }
private fun parseEpochMillis(raw: String): Long? { private fun parseEpochMillis(raw: String): Long? {

View File

@@ -166,7 +166,7 @@ class CardDetailApiCompatibilityTest {
} }
@Test @Test
fun listCardActivities_triesEndpointVariants_thenReturnsNewestFirstTopTen() = runTest { fun listCardActivities_fallsBackToThirdEndpoint_whenFirstTwo2xxPayloadsAreUnparseable() = runTest {
TestServer().use { server -> TestServer().use { server ->
server.register( server.register(
path = "/api/v1/cards/card-3/activities?limit=50", path = "/api/v1/cards/card-3/activities?limit=50",
@@ -174,12 +174,18 @@ class CardDetailApiCompatibilityTest {
status = 200, status = 200,
responseBody = "{\"data\":\"not-an-array\"}", responseBody = "{\"data\":\"not-an-array\"}",
) )
server.register(
path = "/api/v1/cards/card-3/actions?limit=50",
method = "GET",
status = 200,
responseBody = "{\"actions\":{\"unexpected\":true}}",
)
val secondPayloadItems = (1..12).joinToString(",") { index -> val secondPayloadItems = (1..12).joinToString(",") { index ->
val day = index.toString().padStart(2, '0') val day = index.toString().padStart(2, '0')
"""{"id":"a-$index","type":"comment","text":"Item $index","createdAt":"2026-01-${day}T00:00:00Z"}""" """{"id":"a-$index","type":"comment","text":"Item $index","createdAt":"2026-01-${day}T00:00:00Z"}"""
} }
server.register( server.register(
path = "/api/v1/cards/card-3/actions?limit=50", path = "/api/v1/cards/card-3/card-activities?limit=50",
method = "GET", method = "GET",
status = 200, status = 200,
responseBody = "{\"data\":[${secondPayloadItems}]}", responseBody = "{\"data\":[${secondPayloadItems}]}",
@@ -194,7 +200,7 @@ class CardDetailApiCompatibilityTest {
val requests = server.findRequests("/api/v1/cards/card-3/activities?limit=50") + val requests = server.findRequests("/api/v1/cards/card-3/activities?limit=50") +
server.findRequests("/api/v1/cards/card-3/actions?limit=50") + server.findRequests("/api/v1/cards/card-3/actions?limit=50") +
server.findRequests("/api/v1/cards/card-3/card-activities?limit=50") server.findRequests("/api/v1/cards/card-3/card-activities?limit=50")
assertEquals(2, requests.size) assertEquals(3, requests.size)
assertEquals( assertEquals(
LocalDate.of(2026, 1, 12).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(), LocalDate.of(2026, 1, 12).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(),
activities.first().createdAtEpochMillis, activities.first().createdAtEpochMillis,
@@ -203,25 +209,33 @@ class CardDetailApiCompatibilityTest {
} }
@Test @Test
fun addCardComment_fallbacksOnAmbiguousSuccess_andStopsOnLogicalFailure() = runTest { fun addCardComment_continuesFallbackAfter2xxLogicalFailure_untilLaterVariantSucceeds() = runTest {
TestServer().use { server -> TestServer().use { server ->
server.registerSequence( server.registerSequence(
path = "/api/v1/cards/card-4/comment-actions", path = "/api/v1/cards/card-4/comment-actions",
method = "POST", method = "POST",
responses = listOf( responses = listOf(
200 to "{\"maybe\":\"unknown\"}",
200 to "{\"success\":false,\"message\":\"blocked\"}", 200 to "{\"success\":false,\"message\":\"blocked\"}",
500 to "{}",
), ),
) )
server.register(
path = "/api/v1/cards/card-4/actions/comments",
method = "POST",
status = 200,
responseBody = "{\"action\":{\"id\":\"act-1\"}}",
)
val result = HttpKanbnApiClient().addCardComment(server.baseUrl, "api", "card-4", "A comment") val result = HttpKanbnApiClient().addCardComment(server.baseUrl, "api", "card-4", "A comment")
assertTrue(result is BoardsApiResult.Failure) assertTrue(result is BoardsApiResult.Success<*>)
val requests = server.findRequests("/api/v1/cards/card-4/comment-actions") val requests = server.findRequests("/api/v1/cards/card-4/comment-actions")
assertEquals(2, requests.size) assertEquals(2, requests.size)
assertEquals("{\"text\":\"A comment\"}", requests[0].body) assertEquals("{\"text\":\"A comment\"}", requests[0].body)
assertEquals("{\"comment\":\"A comment\"}", requests[1].body) assertEquals("{\"comment\":\"A comment\"}", requests[1].body)
assertTrue(server.findRequests("/api/v1/cards/card-4/actions/comments").isEmpty()) val thirdRequests = server.findRequests("/api/v1/cards/card-4/actions/comments")
assertEquals(1, thirdRequests.size)
assertEquals("{\"text\":\"A comment\"}", thirdRequests[0].body)
} }
} }