diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt index fa99398..eca7e55 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt @@ -757,7 +757,8 @@ class HttpKanbnApiClient : KanbnApiClient { when (evaluateCommentResponse(response.body)) { CommentResponseClassification.Success -> return@withContext BoardsApiResult.Success(Unit) CommentResponseClassification.LogicalFailure -> { - return@withContext BoardsApiResult.Failure(extractLogicalFailureMessage(response.body)) + lastFailure = extractLogicalFailureMessage(response.body) + continue } CommentResponseClassification.ParseIncompatible -> continue } @@ -1175,30 +1176,30 @@ class HttpKanbnApiClient : KanbnApiClient { } val parsed = parseJsonValue(body) ?: return null - val items = when (parsed) { - is List<*> -> parsed.mapNotNull { it as? Map<*, *> } - is Map<*, *> -> extractActivityArray(parsed) - else -> return null - } + val items: List> = when (parsed) { + is List<*> -> { + if (parsed.isEmpty()) { + return emptyList() + } + if (parsed.any { it !is Map<*, *> }) { + return null + } + @Suppress("UNCHECKED_CAST") + parsed as List> + } - if (items.isEmpty()) { - val trimmed = body.trim() - if (trimmed == "[]") { - return emptyList() + is Map<*, *> -> { + val extracted = extractActivityContainer(parsed) ?: return null + if (extracted.any { it !is Map<*, *> }) { + return null + } + if (extracted.isEmpty()) { + return emptyList() + } + extracted.mapNotNull { it as? Map<*, *> } } - val asMap = parsed as? Map<*, *> ?: return emptyList() - 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 emptyList() + + else -> return null } return items.mapNotNull { raw -> @@ -1218,13 +1219,24 @@ class HttpKanbnApiClient : KanbnApiClient { } } - private fun extractActivityArray(source: Map<*, *>): List> { - val topLevel = extractObjectArray(source, "activities", "actions", "cardActivities", "card_activities", "items", "data") - if (topLevel.isNotEmpty()) { - return topLevel + private fun extractActivityContainer(source: Map<*, *>): List<*>? { + val keys = listOf("activities", "actions", "cardActivities", "card_activities", "items", "data") + keys.forEach { key -> + 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? { diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/CardDetailApiCompatibilityTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/CardDetailApiCompatibilityTest.kt index d1ff6b2..255007b 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/CardDetailApiCompatibilityTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/CardDetailApiCompatibilityTest.kt @@ -166,7 +166,7 @@ class CardDetailApiCompatibilityTest { } @Test - fun listCardActivities_triesEndpointVariants_thenReturnsNewestFirstTopTen() = runTest { + fun listCardActivities_fallsBackToThirdEndpoint_whenFirstTwo2xxPayloadsAreUnparseable() = runTest { TestServer().use { server -> server.register( path = "/api/v1/cards/card-3/activities?limit=50", @@ -174,12 +174,18 @@ class CardDetailApiCompatibilityTest { status = 200, 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 day = index.toString().padStart(2, '0') """{"id":"a-$index","type":"comment","text":"Item $index","createdAt":"2026-01-${day}T00:00:00Z"}""" } server.register( - path = "/api/v1/cards/card-3/actions?limit=50", + path = "/api/v1/cards/card-3/card-activities?limit=50", method = "GET", status = 200, responseBody = "{\"data\":[${secondPayloadItems}]}", @@ -194,7 +200,7 @@ class CardDetailApiCompatibilityTest { 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/card-activities?limit=50") - assertEquals(2, requests.size) + assertEquals(3, requests.size) assertEquals( LocalDate.of(2026, 1, 12).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(), activities.first().createdAtEpochMillis, @@ -203,25 +209,33 @@ class CardDetailApiCompatibilityTest { } @Test - fun addCardComment_fallbacksOnAmbiguousSuccess_andStopsOnLogicalFailure() = runTest { + fun addCardComment_continuesFallbackAfter2xxLogicalFailure_untilLaterVariantSucceeds() = runTest { TestServer().use { server -> server.registerSequence( path = "/api/v1/cards/card-4/comment-actions", method = "POST", responses = listOf( - 200 to "{\"maybe\":\"unknown\"}", 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") - assertTrue(result is BoardsApiResult.Failure) + assertTrue(result is BoardsApiResult.Success<*>) val requests = server.findRequests("/api/v1/cards/card-4/comment-actions") assertEquals(2, requests.size) assertEquals("{\"text\":\"A comment\"}", requests[0].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) } }