fix: continue card-detail fallbacks on 2xx incompatibility
This commit is contained in:
@@ -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,31 +1176,31 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.isEmpty()) {
|
|
||||||
val trimmed = body.trim()
|
|
||||||
if (trimmed == "[]") {
|
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
val asMap = parsed as? Map<*, *> ?: return emptyList()
|
if (parsed.any { it !is Map<*, *> }) {
|
||||||
val hadContainer = sequenceOf(
|
|
||||||
"activities",
|
|
||||||
"actions",
|
|
||||||
"cardActivities",
|
|
||||||
"card_activities",
|
|
||||||
"items",
|
|
||||||
"data",
|
|
||||||
).any { key -> asMap.containsKey(key) || ((asMap["data"] as? Map<*, *>)?.containsKey(key) == true) }
|
|
||||||
if (hadContainer) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
parsed as List<Map<*, *>>
|
||||||
|
}
|
||||||
|
|
||||||
|
is Map<*, *> -> {
|
||||||
|
val extracted = extractActivityContainer(parsed) ?: return null
|
||||||
|
if (extracted.any { it !is Map<*, *> }) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (extracted.isEmpty()) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
extracted.mapNotNull { it as? Map<*, *> }
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
|
||||||
return items.mapNotNull { raw ->
|
return items.mapNotNull { raw ->
|
||||||
val id = extractString(raw, "id", "publicId", "public_id")
|
val id = extractString(raw, "id", "publicId", "public_id")
|
||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user