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>,
): BoardsApiResult<CreatedEntityRef> {
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\""
}
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(
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<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(
baseUrl: String,
apiKey: String,
@@ -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 ->