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 03e4b29..9c38664 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 @@ -387,25 +387,47 @@ class HttpKanbnApiClient : KanbnApiClient { tagPublicIds: List, ): BoardsApiResult { return withContext(Dispatchers.IO) { - val payloadFields = mutableListOf( - "\"listPublicId\":\"${jsonEscape(listPublicId)}\"", - "\"title\":\"${jsonEscape(title)}\"", - "\"index\":0", - "\"labelPublicIds\":${jsonStringArray(tagPublicIds)}", + val canonicalPayload = buildCreateCardPayload( + listPublicId = listPublicId, + title = title, + description = description, + dueDate = dueDate, + tagPublicIds = tagPublicIds, + aliasKeys = false, ) - if (description != null) { - payloadFields += "\"description\":\"${jsonEscape(description)}\"" + val canonicalResult = request( + baseUrl = baseUrl, + path = "/api/v1/cards", + method = "POST", + apiKey = apiKey, + body = canonicalPayload, + ) { code, body -> + if (code in 200..299) { + parseCreatedEntityRef(body) + ?.let { BoardsApiResult.Success(it) } + ?: BoardsApiResult.Failure("Malformed create card response.") + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } } - if (dueDate != null) { - payloadFields += "\"dueDate\":\"${dueDate}T00:00:00Z\"" + if (canonicalResult is BoardsApiResult.Success) { + return@withContext canonicalResult } - val payload = "{${payloadFields.joinToString(",")}}" + + val aliasPayload = buildCreateCardPayload( + listPublicId = listPublicId, + title = title, + description = description, + dueDate = dueDate, + tagPublicIds = tagPublicIds, + aliasKeys = true, + ) request( baseUrl = baseUrl, path = "/api/v1/cards", method = "POST", apiKey = apiKey, - body = payload, + body = aliasPayload, ) { code, body -> if (code in 200..299) { parseCreatedEntityRef(body) @@ -418,6 +440,44 @@ class HttpKanbnApiClient : KanbnApiClient { } } + private fun buildCreateCardPayload( + listPublicId: String, + title: String, + description: String?, + dueDate: LocalDate?, + tagPublicIds: List, + aliasKeys: Boolean, + ): String { + val payloadFields = mutableListOf( + "\"listPublicId\":\"${jsonEscape(listPublicId)}\"", + if (aliasKeys) { + "\"name\":\"${jsonEscape(title)}\"" + } else { + "\"title\":\"${jsonEscape(title)}\"" + }, + "\"description\":\"${jsonEscape(description.orEmpty())}\"", + "\"labelPublicIds\":${jsonStringArray(tagPublicIds)}", + "\"memberPublicIds\":[]", + "\"position\":\"start\"", + ) + if (aliasKeys) { + payloadFields += if (aliasKeys) { + "\"body\":\"${jsonEscape(description.orEmpty())}\"" + } else { + "\"description\":\"${jsonEscape(description.orEmpty())}\"" + } + payloadFields += "\"index\":0" + } + if (dueDate != null) { + payloadFields += if (aliasKeys) { + "\"dueAt\":\"${dueDate}T00:00:00Z\"" + } else { + "\"dueDate\":\"${dueDate}T00:00:00Z\"" + } + } + return "{${payloadFields.joinToString(",")}}" + } + override suspend fun moveCard( baseUrl: String, apiKey: String, diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt index 952b60c..f64876f 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt @@ -99,7 +99,7 @@ class HttpKanbnApiClientBoardDetailParsingTest { } @Test - fun createCard_sendsTopIndex_andOmittedDueDateWhenNull() = runTest { + fun createCard_sendsRequiredValidationFields_whenOptionalsAreNull() = runTest { TestServer().use { server -> server.register(path = "/api/v1/cards", method = "POST", status = 200, responseBody = """{"publicId":"card-new"}""") @@ -116,9 +116,10 @@ class HttpKanbnApiClientBoardDetailParsingTest { assertTrue(result is BoardsApiResult.Success<*>) val request = server.findRequest("POST", "/api/v1/cards") assertNotNull(request) - assertTrue(request?.body?.contains("\"index\":0") == true) + assertTrue(request?.body?.contains("\"position\":\"start\"") == true) + assertTrue(request?.body?.contains("\"memberPublicIds\":[]") == true) + assertTrue(request?.body?.contains("\"description\":\"\"") == true) assertTrue(request?.body?.contains("\"dueDate\"") == false) - assertTrue(request?.body?.contains("\"description\"") == false) } } @@ -172,6 +173,36 @@ class HttpKanbnApiClientBoardDetailParsingTest { } } + @Test + fun createCard_retriesWithAliasPayloadWhenServerRejectsCanonicalFields() = runTest { + TestServer().use { server -> + server.registerSequence( + path = "/api/v1/cards", + method = "POST", + responses = listOf( + 400 to "{}", + 200 to """{"card":{"publicId":"card-new"}}""", + ), + ) + + val result = HttpKanbnApiClient().createCard( + baseUrl = server.baseUrl, + apiKey = "api", + listPublicId = "list-1", + title = "Card", + description = "Description", + dueDate = LocalDate.of(2026, 3, 16), + tagPublicIds = listOf("tag-1"), + ) + + assertTrue(result is BoardsApiResult.Success<*>) + val requests = server.findRequests("POST", "/api/v1/cards") + assertEquals(2, requests.size) + assertTrue(requests[0].body.contains("\"title\":\"Card\"")) + assertTrue(requests[1].body.contains("\"name\":\"Card\"")) + } + } + @Test fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest { TestServer().use { server ->