fix: satisfy card creation validation payload requirements

Send required create-card fields and retry with alias keys when canonical payload is rejected, so board add-card works across stricter Kan API variants.
This commit is contained in:
2026-04-30 14:43:35 -04:00
parent 784f92bd40
commit 29e859bc01
2 changed files with 105 additions and 14 deletions
@@ -387,25 +387,47 @@ class HttpKanbnApiClient : KanbnApiClient {
tagPublicIds: List<String>, tagPublicIds: List<String>,
): BoardsApiResult<CreatedEntityRef> { ): BoardsApiResult<CreatedEntityRef> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val payloadFields = mutableListOf( val canonicalPayload = buildCreateCardPayload(
"\"listPublicId\":\"${jsonEscape(listPublicId)}\"", listPublicId = listPublicId,
"\"title\":\"${jsonEscape(title)}\"", title = title,
"\"index\":0", description = description,
"\"labelPublicIds\":${jsonStringArray(tagPublicIds)}", dueDate = dueDate,
tagPublicIds = tagPublicIds,
aliasKeys = false,
) )
if (description != null) { val canonicalResult = request(
payloadFields += "\"description\":\"${jsonEscape(description)}\"" 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\""
} }
val payload = "{${payloadFields.joinToString(",")}}" if (canonicalResult is BoardsApiResult.Success) {
return@withContext canonicalResult
}
val aliasPayload = buildCreateCardPayload(
listPublicId = listPublicId,
title = title,
description = description,
dueDate = dueDate,
tagPublicIds = tagPublicIds,
aliasKeys = true,
)
request( request(
baseUrl = baseUrl, baseUrl = baseUrl,
path = "/api/v1/cards", path = "/api/v1/cards",
method = "POST", method = "POST",
apiKey = apiKey, apiKey = apiKey,
body = payload, body = aliasPayload,
) { code, body -> ) { code, body ->
if (code in 200..299) { if (code in 200..299) {
parseCreatedEntityRef(body) parseCreatedEntityRef(body)
@@ -418,6 +440,44 @@ class HttpKanbnApiClient : KanbnApiClient {
} }
} }
private fun buildCreateCardPayload(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: List<String>,
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( override suspend fun moveCard(
baseUrl: String, baseUrl: String,
apiKey: String, apiKey: String,
@@ -99,7 +99,7 @@ class HttpKanbnApiClientBoardDetailParsingTest {
} }
@Test @Test
fun createCard_sendsTopIndex_andOmittedDueDateWhenNull() = runTest { fun createCard_sendsRequiredValidationFields_whenOptionalsAreNull() = runTest {
TestServer().use { server -> TestServer().use { server ->
server.register(path = "/api/v1/cards", method = "POST", status = 200, responseBody = """{"publicId":"card-new"}""") 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<*>) assertTrue(result is BoardsApiResult.Success<*>)
val request = server.findRequest("POST", "/api/v1/cards") val request = server.findRequest("POST", "/api/v1/cards")
assertNotNull(request) 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("\"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 @Test
fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest { fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest {
TestServer().use { server -> TestServer().use { server ->